Просмотр исходного кода

Merge branch 'nightscout:dev' into override-ns-rendering

Daniel Snällfot 1 год назад
Родитель
Сommit
a2ffff8e2f
48 измененных файлов с 3433 добавлено и 543 удалено
  1. 1 3
      .gitignore
  2. 1 1
      G7SensorKit
  3. 11 0
      Model/Helper/TempTargetStored+Helper.swift
  4. 56 24
      Trio.xcodeproj/project.pbxproj
  5. 54 0
      Trio.xcodeproj/xcshareddata/xcschemes/Trio Tests.xcscheme
  6. 3 23
      Trio/Sources/APS/APSManager.swift
  7. 185 90
      Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift
  8. 24 24
      Trio/Sources/APS/Storage/CarbsStorage.swift
  9. 3 2
      Trio/Sources/APS/Storage/DeterminationStorage.swift
  10. 34 30
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  11. 49 23
      Trio/Sources/APS/Storage/OverrideStorage.swift
  12. 4 2
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  13. 43 22
      Trio/Sources/APS/Storage/TempTargetsStorage.swift
  14. 5 1
      Trio/Sources/Application/TrioApp.swift
  15. 16 7
      Trio/Sources/Helpers/BuildDetails.swift
  16. 159 4
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  17. 1 0
      Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift
  18. 4 1
      Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift
  19. 1 1
      Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift
  20. 4 4
      Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift
  21. 1 1
      Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift
  22. 257 36
      Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift
  23. 23 7
      Trio/Sources/Modules/DataTable/DataTableStateModel.swift
  24. 1 1
      Trio/Sources/Modules/DataTable/View/DataTableRootView.swift
  25. 1 1
      Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift
  26. 1 1
      Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift
  27. 16 12
      Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift
  28. 1 1
      Trio/Sources/Modules/Settings/View/SettingsRootView.swift
  29. 34 0
      Trio/Sources/Modules/Settings/View/Subviews/SubmodulesView.swift
  30. 10 6
      Trio/Sources/Modules/TargetBehavoir/View/TargetBehavoirRootView.swift
  31. 60 35
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  32. 19 8
      Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift
  33. 19 7
      Trio/Sources/Services/WatchManager/GarminManager.swift
  34. 9 0
      Trio/Sources/Shortcuts/AppShortcuts.swift
  35. 115 65
      Trio/Sources/Views/TextFieldWithToolBar.swift
  36. 590 0
      TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift
  37. 46 29
      TrioTests/CalibrationsTests.swift
  38. 208 0
      TrioTests/CoreDataTests/CarbsStorageTests.swift
  39. 230 0
      TrioTests/CoreDataTests/DeterminationStorageTests.swift
  40. 187 0
      TrioTests/CoreDataTests/GlucoseStorageTests.swift
  41. 224 0
      TrioTests/CoreDataTests/OverrideStorageTests.swift
  42. 288 0
      TrioTests/CoreDataTests/PumpHistoryStorageTests.swift
  43. 148 0
      TrioTests/CoreDataTests/TempTargetStorageTests.swift
  44. 44 0
      TrioTests/CoreDataTests/TestAssembly.swift
  45. 127 12
      TrioTests/FileStorageTests.swift
  46. 56 36
      TrioTests/PluginManagerTests.swift
  47. 10 0
      TrioTests/TestError.swift
  48. 50 23
      scripts/capture-build-details.sh

+ 1 - 3
.gitignore

@@ -79,6 +79,4 @@ fastlane/screenshots
 fastlane/test_output
 fastlane/FastlaneRunner
 
-ConfigOverride.xcconfig
-
-branch.txt
+ConfigOverride.xcconfig

+ 1 - 1
G7SensorKit

@@ -1 +1 @@
-Subproject commit 2be3eb29b0a18aa89f8b60281341e46e07d024e5
+Subproject commit 205054e7537723c2aec58d807634b4853f687244

+ 11 - 0
Model/Helper/TempTargetStored+Helper.swift

@@ -14,6 +14,17 @@ extension NSPredicate {
             true as NSNumber
         )
     }
+
+    static var tempTargetsForMainChart: NSPredicate {
+        let date = Date.oneDayAgo
+        return NSPredicate(
+            format: "(date >= %@ AND enabled == %@) OR (date >= %@ AND enabled == %@)",
+            date as NSDate,
+            true as NSNumber,
+            Date() as NSDate,
+            false as NSNumber
+        )
+    }
 }
 
 extension TempTargetStored {

Разница между файлами не показана из-за своего большого размера
+ 56 - 24
Trio.xcodeproj/project.pbxproj


+ 54 - 0
Trio.xcodeproj/xcshareddata/xcschemes/Trio Tests.xcscheme

@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1620"
+   version = "1.7">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES"
+      buildArchitectures = "Automatic">
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      shouldAutocreateTestPlan = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "38FCF3EC25E9028E0078B0D1"
+               BuildableName = "TrioTests.xctest"
+               BlueprintName = "TrioTests"
+               ReferencedContainer = "container:Trio.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 3 - 23
Trio/Sources/APS/APSManager.swift

@@ -945,31 +945,11 @@ final class BaseAPSManager: APSManager, Injectable {
             }
             let af = pref.adjustmentFactor
             let insulin_type = pref.curve
-//            let buildDate = Bundle.main.buildDate // TODO: fix this
+            let buildDate = BuildDetails.default.buildDate()
             let version = Bundle.main.releaseVersionNumber
             let build = Bundle.main.buildVersionNumber
 
-            // Read branch information from branch.txt instead of infoDictionary
-            var branch = "Unknown"
-            if let branchFileURL = Bundle.main.url(forResource: "branch", withExtension: "txt"),
-               let branchFileContent = try? String(contentsOf: branchFileURL)
-            {
-                let lines = branchFileContent.components(separatedBy: .newlines)
-                for line in lines {
-                    let components = line.components(separatedBy: "=")
-                    if components.count == 2 {
-                        let key = components[0].trimmingCharacters(in: .whitespaces)
-                        let value = components[1].trimmingCharacters(in: .whitespaces)
-
-                        if key == "BRANCH" {
-                            branch = value
-                            break
-                        }
-                    }
-                }
-            } else {
-                branch = "Unknown"
-            }
+            var branch = BuildDetails.default.branchAndSha
 
             let copyrightNotice_ = Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String ?? ""
             let pump_ = pumpManager?.localizedTitle ?? ""
@@ -1003,7 +983,7 @@ final class BaseAPSManager: APSManager, Injectable {
                 Build_Number: build ?? "1",
                 Branch: branch,
                 CopyRightNotice: String(copyrightNotice_.prefix(32)),
-                Build_Date: Date(), // TODO: fix this
+                Build_Date: buildDate ?? Date(),
                 Algorithm: algo_,
                 AdjustmentFactor: af,
                 Pump: pump_,

+ 185 - 90
Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift

@@ -10,15 +10,7 @@
 ///
 /// class GlucoseSimulatorSource - main class
 /// protocol BloodGlucoseGenerator
-///  - IntelligentGenerator: BloodGlucoseGenerator
-
-// TODO: Every itteration trend make two steps, but must only one
-
-// TODO: Trend's value sticks to max and min Glucose value (in Glucose Generator)
-
-// TODO: Add reaction to insulin
-
-// TODO: Add probability to set trend's target value. Middle values must have more probability, than max and min.
+///  - OscillatingGenerator: BloodGlucoseGenerator - Generates sinusoidal glucose values around a center point
 
 import Combine
 import Foundation
@@ -26,22 +18,29 @@ import LoopKitUI
 
 // MARK: - Glucose simulator
 
+/// A class that simulates glucose values for testing purposes.
+/// This class implements the GlucoseSource protocol and provides simulated glucose readings
+/// using different generator strategies.
 final class GlucoseSimulatorSource: GlucoseSource {
     var cgmManager: CGMManagerUI?
     var glucoseManager: FetchGlucoseManager?
 
     private enum Config {
-        // min time period to publish data
+        /// Minimum time period between data publications (in seconds)
         static let workInterval: TimeInterval = 300
-        // default BloodGlucose item at first run
-        // 288 = 1 day * 24 hours * 60 minites * 60 seconds / workInterval
+        /// Default number of blood glucose items to generate at first run
+        /// 288 = 1 day * 24 hours * 60 minutes * 60 seconds / workInterval
         static let defaultBGItems = 288
     }
 
+    /// The last glucose value that was generated
     @Persisted(key: "GlucoseSimulatorLastGlucose") private var lastGlucose = 100
 
+    /// The date of the last fetch operation
     @Persisted(key: "GlucoseSimulatorLastFetchDate") private var lastFetchDate: Date! = nil
 
+    /// Initializes the glucose simulator source
+    /// Sets up the initial fetch date if not already set
     init() {
         if lastFetchDate == nil {
             var lastDate = Date()
@@ -52,12 +51,13 @@ final class GlucoseSimulatorSource: GlucoseSource {
         }
     }
 
+    /// The glucose generator used to create simulated values
+    /// Uses OscillatingGenerator to create a sinusoidal pattern around 120 mg/dL
     private lazy var generator: BloodGlucoseGenerator = {
-        IntelligentGenerator(
-            currentGlucose: lastGlucose
-        )
+        OscillatingGenerator()
     }()
 
+    /// Determines if new glucose values can be generated based on the time elapsed since the last fetch
     private var canGenerateNewValues: Bool {
         guard let lastDate = lastFetchDate else { return true }
         if Calendar.current.dateComponents([.second], from: lastDate, to: Date()).second! >= Int(Config.workInterval) {
@@ -67,6 +67,9 @@ final class GlucoseSimulatorSource: GlucoseSource {
         }
     }
 
+    /// Fetches new glucose values if enough time has passed since the last fetch
+    /// - Parameter timer: Optional dispatch timer (not used in this implementation)
+    /// - Returns: A publisher that emits an array of BloodGlucose objects
     func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
         guard canGenerateNewValues else {
             return Just([]).eraseToAnyPublisher()
@@ -86,6 +89,8 @@ final class GlucoseSimulatorSource: GlucoseSource {
         return Just(glucoses).eraseToAnyPublisher()
     }
 
+    /// Fetches new glucose values if needed
+    /// - Returns: A publisher that emits an array of BloodGlucose objects
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
     }
@@ -93,105 +98,195 @@ final class GlucoseSimulatorSource: GlucoseSource {
 
 // MARK: - Glucose generator
 
+/// Protocol defining the interface for glucose generators
+/// Implementations of this protocol provide different strategies for generating glucose values
 protocol BloodGlucoseGenerator {
+    /// Generates blood glucose values between the specified dates at the given interval
+    /// - Parameters:
+    ///   - startDate: The start date for generating values
+    ///   - finishDate: The end date for generating values
+    ///   - interval: The time interval between generated values
+    /// - Returns: An array of BloodGlucose objects
     func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval: TimeInterval) -> [BloodGlucose]
 }
 
-class IntelligentGenerator: BloodGlucoseGenerator {
-    private enum Config {
-        // max and min glucose of trend's target
-        static let maxGlucose = 320
-        static let minGlucose = 45
+/// A glucose generator that creates a sinusoidal pattern around a center value
+/// This generator simulates a realistic oscillating glucose pattern with configurable parameters
+class OscillatingGenerator: BloodGlucoseGenerator {
+    /// Default values for simulator parameters
+    enum Defaults {
+        static let centerValue: Double = 120.0
+        static let amplitude: Double = 45.0
+        static let period: Double = 10800.0 // 3 hours in seconds
+        static let noiseAmplitude: Double = 5.0
+        static let produceStaleValues: Bool = false
     }
 
-    // target glucose of trend
-    @Persisted(key: "GlucoseSimulatorTargetValue") private var trendTargetValue = 100
-    // how many steps left in current trend
-    @Persisted(key: "GlucoseSimulatorTargetSteps") private var trendStepsLeft = 1
-    // direction of last step
-    @Persisted(key: "GlucoseSimulatorDirection") private var trandsStepDirection = BloodGlucose.Direction.flat.rawValue
-    var currentGlucose: Int
-    let startup = Date()
-    init(currentGlucose: Int) {
-        self.currentGlucose = currentGlucose
+    /// UserDefaults keys for storing simulator parameters
+    private enum UserDefaultsKeys {
+        static let centerValue = "GlucoseSimulator_CenterValue"
+        static let amplitude = "GlucoseSimulator_Amplitude"
+        static let period = "GlucoseSimulator_Period"
+        static let noiseAmplitude = "GlucoseSimulator_NoiseAmplitude"
+        static let produceStaleValues = "GlucoseSimulator_ProduceStaleValues"
     }
 
-    func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval interval: TimeInterval) -> [BloodGlucose] {
-        var result = [BloodGlucose]()
-
-        var _currentDate = startDate
-        while _currentDate <= finishDate {
-            result.append(getNextBloodGlucose(forDate: _currentDate))
-            _currentDate = _currentDate.addingTimeInterval(interval)
-        }
+    /// Amplitude of the oscillation (±45 mg/dL to create range from ~80 to ~170)
+    private var amplitude: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.amplitude) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.amplitude) :
+            Defaults.amplitude }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.amplitude) }
+    }
 
-        return result
+    /// Period of the oscillation in seconds (3 hours = 10800 seconds)
+    private var period: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.period) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.period) :
+            Defaults.period }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.period) }
     }
 
-    // get next glucose's value in current trend
-    private func getNextBloodGlucose(forDate date: Date) -> BloodGlucose {
-        let previousGlucose = currentGlucose
-        makeStepInTrend()
-        trandsStepDirection = getDirection(fromGlucose: previousGlucose, toGlucose: currentGlucose).rawValue
-        let glucose = BloodGlucose(
-            _id: UUID().uuidString,
-            sgv: currentGlucose,
-            direction: BloodGlucose.Direction(rawValue: trandsStepDirection),
-            date: Decimal(Int(date.timeIntervalSince1970) * 1000),
-            dateString: date,
-            unfiltered: Decimal(currentGlucose),
-            filtered: nil,
-            noise: nil,
-            glucose: currentGlucose,
-            type: nil,
-            activationDate: startup,
-            sessionStartDate: startup,
-            transmitterID: "SIMULATOR"
-        )
-        return glucose
+    /// Center value of the oscillation (target glucose level)
+    private var centerValue: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.centerValue) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.centerValue) :
+            Defaults.centerValue }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.centerValue) }
     }
 
-    private func setNewRandomTarget() {
-        guard trendTargetValue > 0 else {
-            trendTargetValue = Array(80 ... 110).randomElement()!
-            return
-        }
-        let difference = (Array(-50 ... -20) + Array(20 ... 50)).randomElement()!
-        let _value = trendTargetValue + difference
-        if _value <= Config.minGlucose {
-            trendTargetValue = Config.minGlucose
-        } else if _value >= Config.maxGlucose {
-            trendTargetValue = Config.maxGlucose
-        } else {
-            trendTargetValue = _value
-        }
+    /// Amplitude of random noise to add to the values (±5 mg/dL)
+    private var noiseAmplitude: Double {
+        get { UserDefaults.standard.double(forKey: UserDefaultsKeys.noiseAmplitude) != 0 ?
+            UserDefaults.standard.double(forKey: UserDefaultsKeys.noiseAmplitude) :
+            Defaults.noiseAmplitude }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.noiseAmplitude) }
     }
 
-    private func setNewRandomSteps() {
-        trendStepsLeft = Array(3 ... 8).randomElement()!
+    /// Whether to produce stale (unchanging) glucose values
+    var produceStaleValues: Bool {
+        get { UserDefaults.standard.bool(forKey: UserDefaultsKeys.produceStaleValues) }
+        set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.produceStaleValues) }
     }
 
-    private func getDirection(fromGlucose from: Int, toGlucose to: Int) -> BloodGlucose.Direction {
-        BloodGlucose.Direction(trend: Int(to - from))
+    /// Start date for the simulation
+    private let startup = Date()
+
+    /// Last generated glucose value for stale mode
+    private var lastGeneratedGlucose: Int?
+
+    /// Provides information string to describe the simulator as glucose source
+    func sourceInfo() -> [String: Any]? {
+        [GlucoseSourceKey.description.rawValue: "Glucose simulator"]
     }
 
-    private func generateNewTrend() {
-        setNewRandomTarget()
-        setNewRandomSteps()
+    /// Reset all parameters to default values
+    func resetToDefaults() {
+        centerValue = Defaults.centerValue
+        amplitude = Defaults.amplitude
+        period = Defaults.period
+        noiseAmplitude = Defaults.noiseAmplitude
+        produceStaleValues = Defaults.produceStaleValues
+        lastGeneratedGlucose = nil
     }
 
-    private func makeStepInTrend() {
-        guard trendStepsLeft > 0 else { return }
+    /// Generates blood glucose values between the specified dates at the given interval
+    /// - Parameters:
+    ///   - startDate: The start date for generating values
+    ///   - finishDate: The end date for generating values
+    ///   - interval: The time interval between generated values
+    /// - Returns: An array of BloodGlucose objects with sinusoidal pattern
+    func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval interval: TimeInterval) -> [BloodGlucose] {
+        var result = [BloodGlucose]()
+        var currentDate = startDate
+
+        while currentDate <= finishDate {
+            let glucose: Int
+            let direction: BloodGlucose.Direction
+
+            if produceStaleValues, lastGeneratedGlucose != nil {
+                // In stale mode, use the last generated glucose value
+                glucose = lastGeneratedGlucose!
+                direction = .flat
+            } else {
+                // Generate a new glucose value
+                glucose = generate(date: currentDate)
+                direction = calculateDirection(at: currentDate)
+                lastGeneratedGlucose = glucose
+            }
 
-        currentGlucose +=
-            Int(Double((trendTargetValue - currentGlucose) / trendStepsLeft) * [0.3, 0.6, 1, 1.3, 1.6, 2.0].randomElement()!)
-        trendStepsLeft -= 1
-        if trendStepsLeft == 0 {
-            generateNewTrend()
+            // Create BloodGlucose with the correct constructor
+            let bloodGlucose = BloodGlucose(
+                _id: UUID().uuidString,
+                sgv: glucose,
+                direction: direction,
+                date: Decimal(Int(currentDate.timeIntervalSince1970) * 1000),
+                dateString: currentDate,
+                unfiltered: Decimal(glucose),
+                filtered: nil,
+                noise: nil,
+                glucose: glucose,
+                type: nil,
+                activationDate: startup,
+                sessionStartDate: startup,
+                transmitterID: "SIMULATOR"
+            )
+
+            result.append(bloodGlucose)
+            currentDate = currentDate.addingTimeInterval(interval)
         }
+
+        return result
     }
 
-    func sourceInfo() -> [String: Any]? {
-        [GlucoseSourceKey.description.rawValue: "Glucose simulator"]
+    /// Generates a glucose value for the specified date using a sinusoidal function
+    /// - Parameter date: The date for which to generate the glucose value
+    /// - Returns: An integer representing the glucose value in mg/dL
+    private func generate(date: Date) -> Int {
+        // Time in seconds since 1970
+        let timeSeconds = date.timeIntervalSince1970
+
+        // Calculate sine value
+        let sinValue = sin(2.0 * .pi * timeSeconds / period)
+
+        // Random noise
+        let noise = Double.random(in: -noiseAmplitude ... noiseAmplitude)
+
+        // Calculate glucose value: center + amplitude * sine + noise
+        let glucoseValue = centerValue + amplitude * sinValue + noise
+
+        // Return as integer
+        return Int(glucoseValue)
+    }
+
+    /// Calculates the direction (trend) of glucose change at the specified date
+    /// - Parameter date: The date for which to calculate the direction
+    /// - Returns: A BloodGlucose.Direction value indicating the trend
+    private func calculateDirection(at date: Date) -> BloodGlucose.Direction {
+        // Time in seconds since 1970
+        let timeSeconds = date.timeIntervalSince1970
+
+        // Calculate derivative of sine function (cosine)
+        let cosValue = cos(2.0 * .pi * timeSeconds / period)
+
+        // Slope of the curve at this point
+        let slope = -amplitude * 2.0 * .pi / period * cosValue
+
+        // Determine direction based on slope
+        if abs(slope) < 0.2 {
+            return .flat
+        } else if slope > 0 {
+            if slope > 1.0 {
+                return .singleUp
+            } else {
+                return .fortyFiveUp
+            }
+        } else {
+            if slope < -1.0 {
+                return .singleDown
+            } else {
+                return .fortyFiveDown
+            }
+        }
     }
 }

+ 24 - 24
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -13,7 +13,6 @@ protocol CarbsStorage {
     func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async throws
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async
     func syncDate() -> Date
-    func recent() -> [CarbsEntry]
     func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
     func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
     func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry]
@@ -26,15 +25,16 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var settings: SettingsManager!
 
-    let coredataContext = CoreDataStack.shared.newTaskContext()
-
     private let updateSubject = PassthroughSubject<Void, Never>()
 
     var updatePublisher: AnyPublisher<Void, Never> {
         updateSubject.eraseToAnyPublisher()
     }
 
-    init(resolver: Resolver) {
+    private let context: NSManagedObjectContext
+
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
@@ -75,7 +75,7 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         // Fetch only the date property from Core Data
         guard let existing24hCarbEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.predicateForOneDayAgo,
             key: "date",
             ascending: false,
@@ -217,8 +217,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
         guard let entry = entries.last else { return }
 
-        await coredataContext.perform {
-            let newItem = CarbEntryStored(context: self.coredataContext)
+        await context.perform {
+            let newItem = CarbEntryStored(context: self.context)
             newItem.date = entry.actualDate ?? entry.createdAt
             newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
             newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
@@ -235,8 +235,8 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             }
 
             do {
-                guard self.coredataContext.hasChanges else { return }
-                try self.coredataContext.save()
+                guard self.context.hasChanges else { return }
+                try self.context.save()
             } catch {
                 print(error.localizedDescription)
             }
@@ -264,9 +264,9 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             // do NOT set Health and Tidepool flags to ensure they will NOT be uploaded
             return false // return false to continue
         }
-        await coredataContext.perform {
+        await context.perform {
             do {
-                try self.coredataContext.execute(batchInsert)
+                try self.context.execute(batchInsert)
                 debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
 
                 // Notify subscriber in Home State Model to update the FPU Array
@@ -281,12 +281,12 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         Date().addingTimeInterval(-1.days.timeInterval)
     }
 
-    func recent() -> [CarbsEntry] {
-        storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self)?.reversed() ?? []
-    }
-
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async {
-        let taskContext = CoreDataStack.shared.newTaskContext()
+        // Use injected context if available, otherwise create new task context
+        let taskContext = context != CoreDataStack.shared.newTaskContext()
+            ? context
+            : CoreDataStack.shared.newTaskContext()
+
         taskContext.name = "deleteContext"
         taskContext.transactionAuthor = "deleteCarbs"
 
@@ -342,13 +342,13 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.carbsNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let carbEntries = results as? [CarbEntryStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -381,13 +381,13 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.fpusNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let fpuEntries = results as? [CarbEntryStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -420,13 +420,13 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.carbsNotYetUploadedToHealth,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let carbEntries = results as? [CarbEntryStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -451,13 +451,13 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
     func getCarbsNotYetUploadedToTidepool() async throws -> [CarbsEntry] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.carbsNotYetUploadedToTidepool,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let carbEntries = results as? [CarbEntryStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }

+ 3 - 2
Trio/Sources/APS/Storage/DeterminationStorage.swift

@@ -18,9 +18,10 @@ protocol DeterminationStorage {
 
 final class BaseDeterminationStorage: DeterminationStorage, Injectable {
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let context = CoreDataStack.shared.newTaskContext()
+    private let context: NSManagedObjectContext
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 

+ 34 - 30
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -33,8 +33,6 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var settingsManager: SettingsManager!
 
-    let coredataContext = CoreDataStack.shared.newTaskContext()
-
     private let updateSubject = PassthroughSubject<Void, Never>()
 
     var updatePublisher: AnyPublisher<Void, Never> {
@@ -45,7 +43,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         static let filterTime: TimeInterval = 3.5 * 60
     }
 
-    init(resolver: Resolver) {
+    private let context: NSManagedObjectContext
+
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
@@ -61,7 +62,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func storeGlucose(_ glucose: [BloodGlucose]) async throws {
-        try await coredataContext.perform {
+        try await context.perform {
             // Get new glucose values that don't exist yet
             let newGlucose = self.filterNewGlucoseValues(glucose)
             guard !newGlucose.isEmpty else { return }
@@ -93,7 +94,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
         var existingDates = Set<Date>()
         do {
-            let results = try coredataContext.fetch(fetchRequest) as? [NSDictionary]
+            let results = try context.fetch(fetchRequest) as? [NSDictionary]
             existingDates = Set(results?.compactMap({ $0["date"] as? Date }) ?? [])
         } catch {
             debugPrint("Failed to fetch existing glucose dates: \(error)")
@@ -112,12 +113,12 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     private func storeGlucoseRegular(_ glucose: [BloodGlucose]) throws {
         for entry in glucose {
-            let glucoseEntry = GlucoseStored(context: coredataContext)
+            let glucoseEntry = GlucoseStored(context: context)
             configureGlucoseEntry(glucoseEntry, with: entry)
         }
 
-        guard coredataContext.hasChanges else { return }
-        try coredataContext.save()
+        guard context.hasChanges else { return }
+        try context.save()
     }
 
     private func storeGlucoseBatch(_ glucose: [BloodGlucose]) throws {
@@ -135,7 +136,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
                 return false
             }
         )
-        try coredataContext.execute(batchInsert)
+        try context.execute(batchInsert)
         // Only send update for batch insert since regular save triggers CoreData notifications
         updateSubject.send()
     }
@@ -218,8 +219,8 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func addManualGlucose(glucose: Int) {
-        coredataContext.perform {
-            let newItem = GlucoseStored(context: self.coredataContext)
+        context.perform {
+            let newItem = GlucoseStored(context: self.context)
             newItem.id = UUID()
             newItem.date = Date()
             newItem.glucose = Int16(glucose)
@@ -229,8 +230,8 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             newItem.isUploadedToTidepool = false
 
             do {
-                guard self.coredataContext.hasChanges else { return }
-                try self.coredataContext.save()
+                guard self.context.hasChanges else { return }
+                try self.context.save()
 
                 // Glucose subscribers already listen to the update publisher, so call here to update glucose-related data.
                 self.updateSubject.send()
@@ -281,9 +282,9 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         fr.fetchLimit = 1
 
         var date: Date?
-        coredataContext.performAndWait {
+        context.performAndWait {
             do {
-                let results = try self.coredataContext.fetch(fr)
+                let results = try self.context.fetch(fr)
                 date = results.first?.date
             } catch let error as NSError {
                 print("Fetch error: \(DebuggingIdentifiers.failed) \(error.localizedDescription), \(error.userInfo)")
@@ -317,7 +318,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         let predicate = NSPredicate.predicateFor20MinAgo
         return (try CoreDataStack.shared.fetchEntities(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: predicate,
             key: "date",
             ascending: false,
@@ -330,13 +331,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     func getGlucoseNotYetUploadedToNightscout() async throws -> [BloodGlucose] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.glucoseNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -363,13 +364,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     func getManualGlucoseNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.manualGlucoseNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -419,13 +420,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     func getGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.glucoseNotYetUploadedToHealth,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -451,13 +452,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     func getManualGlucoseNotYetUploadedToHealth() async throws -> [BloodGlucose] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.manualGlucoseNotYetUploadedToHealth,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -483,13 +484,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     func getGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.glucoseNotYetUploadedToTidepool,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -516,13 +517,13 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     func getManualGlucoseNotYetUploadedToTidepool() async throws -> [StoredGlucoseSample] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
-            onContext: coredataContext,
+            onContext: context,
             predicate: NSPredicate.manualGlucoseNotYetUploadedToTidepool,
             key: "date",
             ascending: false
         )
 
-        return try await coredataContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -544,7 +545,10 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
     }
 
     func deleteGlucose(_ treatmentObjectID: NSManagedObjectID) async {
-        let taskContext = CoreDataStack.shared.newTaskContext()
+        // Use injected context if available, otherwise create new task context
+        let taskContext = context != CoreDataStack.shared.newTaskContext()
+            ? context
+            : CoreDataStack.shared.newTaskContext()
         taskContext.name = "deleteContext"
         taskContext.transactionAuthor = "deleteGlucose"
 
@@ -572,7 +576,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
     var alarm: GlucoseAlarm? {
         /// glucose can not be older than 20 minutes due to the predicate in the fetch request
-        coredataContext.performAndWait {
+        context.performAndWait {
             do {
                 guard let glucose = try fetchLatestGlucose() else { return nil }
 

+ 49 - 23
Trio/Sources/APS/Storage/OverrideStorage.swift

@@ -25,9 +25,10 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     @Injected() private var settingsManager: SettingsManager!
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
+    private let context: NSManagedObjectContext
 
-    init(resolver: Resolver) {
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
@@ -42,7 +43,7 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     func fetchLastCreatedOverride() async throws -> [NSManagedObjectID] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate(
                 format: "date >= %@",
                 Date.oneDayAgo as NSDate
@@ -52,7 +53,7 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
             fetchLimit: 1
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OverrideStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -64,14 +65,14 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     func loadLatestOverrideConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveOverride,
             key: "orderPosition",
             ascending: true,
             fetchLimit: fetchLimit
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OverrideStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -84,13 +85,13 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     func fetchForOverridePresets() async throws -> [NSManagedObjectID] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.allOverridePresets,
             key: "orderPosition",
             ascending: true
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OverrideStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -113,8 +114,8 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
             presetCount = presets.count
         }
 
-        try await backgroundContext.perform {
-            let newOverride = OverrideStored(context: self.backgroundContext)
+        try await context.perform {
+            let newOverride = OverrideStored(context: self.context)
 
             // override key meta data
             if !override.name.isEmpty {
@@ -162,8 +163,8 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
                 newOverride.smbIsScheduledOff = false
             }
 
-            guard self.backgroundContext.hasChanges else { return }
-            try self.backgroundContext.save()
+            guard self.context.hasChanges else { return }
+            try self.context.save()
         }
     }
 
@@ -205,22 +206,47 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
         return newOverride.objectID
     }
 
-    /// marked as MainActor to be able to publish changes from the background
     /// - Parameter: NSManagedObjectID to be able to transfer the object safely from one thread to another thread
-    @MainActor func deleteOverridePreset(_ objectID: NSManagedObjectID) async {
-        await CoreDataStack.shared.deleteObject(identifiedBy: objectID)
+    func deleteOverridePreset(_ objectID: NSManagedObjectID) async {
+        // Use injected context if available, otherwise create new task context
+        let taskContext = context != CoreDataStack.shared.newTaskContext()
+            ? context
+            : CoreDataStack.shared.newTaskContext()
+
+        taskContext.name = "deleteContext"
+        taskContext.transactionAuthor = "deleteOverride"
+
+        await taskContext.perform {
+            do {
+                guard let override = try taskContext.existingObject(with: objectID) as? OverrideStored else {
+                    debugPrint("Override for batch delete not found. \(DebuggingIdentifiers.failed)")
+                    return
+                }
+
+                taskContext.delete(override)
+
+                guard taskContext.hasChanges else { return }
+                try taskContext.save()
+
+                debugPrint(
+                    "OverrideStorage: \(#function) \(DebuggingIdentifiers.succeeded) deleted override from core data"
+                )
+            } catch {
+                debugPrint("\(DebuggingIdentifiers.failed) Error deleting override: \(error.localizedDescription)")
+            }
+        }
     }
 
     func getOverridesNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveAdjustmentNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedOverrides = results as? [OverrideStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -242,7 +268,7 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     func getOverrideRunsNotYetUploadedToNightscout() async throws -> [NightscoutExercise] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideRunStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate(
                 format: "startDate >= %@ AND isUploadedToNS == %@",
                 Date.oneDayAgo as NSDate,
@@ -252,7 +278,7 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
             ascending: false
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedOverrideRuns = results as? [OverrideRunStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -331,13 +357,13 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     func getPresetOverridesForNightscout() async throws -> [NightscoutPresetOverride] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.allOverridePresets,
             key: "orderPosition",
             ascending: true
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OverrideStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -360,14 +386,14 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     func fetchLatestActiveOverride() async throws -> NSManagedObjectID? {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: OverrideStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveOverride,
             key: "date",
             ascending: false,
             fetchLimit: 1
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [OverrideStored],
                   let latestOverride = fetchedResults.first
             else {

+ 4 - 2
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -26,13 +26,15 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
     @Injected() private var settings: SettingsManager!
 
     private let updateSubject = PassthroughSubject<Void, Never>()
-    private let context = CoreDataStack.shared.newTaskContext()
 
     var updatePublisher: AnyPublisher<Void, Never> {
         updateSubject.eraseToAnyPublisher()
     }
 
-    init(resolver: Resolver) {
+    private let context: NSManagedObjectContext
+
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 

+ 43 - 22
Trio/Sources/APS/Storage/TempTargetsStorage.swift

@@ -31,24 +31,26 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var settingsManager: SettingsManager!
 
-    private let backgroundContext = CoreDataStack.shared.newTaskContext()
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
 
-    init(resolver: Resolver) {
+    private let context: NSManagedObjectContext
+
+    init(resolver: Resolver, context: NSManagedObjectContext? = nil) {
+        self.context = context ?? CoreDataStack.shared.newTaskContext()
         injectServices(resolver)
     }
 
     func loadLatestTempTargetConfigurations(fetchLimit: Int) async throws -> [NSManagedObjectID] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveTempTarget,
             key: "orderPosition",
             ascending: true,
             fetchLimit: fetchLimit
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [TempTargetStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -61,13 +63,13 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     func fetchForTempTargetPresets() async throws -> [NSManagedObjectID] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.allTempTargetPresets,
             key: "orderPosition",
             ascending: true
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [TempTargetStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -81,13 +83,13 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: scheduledTempTargets,
             key: "date",
             ascending: false
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [TempTargetStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -101,14 +103,14 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
 
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: predicate,
             key: "date",
             ascending: false,
             fetchLimit: 1
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedResults = results as? [TempTargetStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -124,8 +126,8 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
             presetCount = presets.count
         }
 
-        try await backgroundContext.perform {
-            let newTempTarget = TempTargetStored(context: self.backgroundContext)
+        try await context.perform {
+            let newTempTarget = TempTargetStored(context: self.context)
             newTempTarget.date = tempTarget.createdAt
             newTempTarget.id = UUID()
             newTempTarget.enabled = tempTarget.enabled ?? false
@@ -149,8 +151,8 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
             }
 
             do {
-                guard self.backgroundContext.hasChanges else { return }
-                try self.backgroundContext.save()
+                guard self.context.hasChanges else { return }
+                try self.context.save()
             } catch let error as NSError {
                 debug(.default, "\(DebuggingIdentifiers.failed) Failed to save new temp target with error: \(error.userInfo)")
                 throw error
@@ -180,13 +182,13 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     }
 
     func existsTempTarget(with date: Date) async -> Bool {
-        await backgroundContext.perform {
+        await context.perform {
             // Fetch all Temp Targets with the given date
             let fetchRequest: NSFetchRequest<TempTargetStored> = TempTargetStored.fetchRequest()
             fetchRequest.predicate = NSPredicate(format: "date == %@", date as NSDate)
 
             do {
-                let results = try self.backgroundContext.fetch(fetchRequest)
+                let results = try self.context.fetch(fetchRequest)
                 return !results.isEmpty
             } catch let error as NSError {
                 debugPrint("\(DebuggingIdentifiers.failed) Failed to check for existing Temp Target: \(error)")
@@ -223,8 +225,27 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
         return newTempTarget.objectID
     }
 
-    @MainActor func deleteTempTargetPreset(_ objectID: NSManagedObjectID) async {
-        await CoreDataStack.shared.deleteObject(identifiedBy: objectID)
+    func deleteTempTargetPreset(_ objectID: NSManagedObjectID) async {
+        let taskContext = context != CoreDataStack.shared.newTaskContext()
+            ? context
+            : CoreDataStack.shared.newTaskContext()
+
+        await taskContext.perform {
+            do {
+                let result = try taskContext.existingObject(with: objectID) as? TempTargetStored
+                guard let tempTarget = result else {
+                    debug(.default, "\(DebuggingIdentifiers.failed) Temp Target for batch delete not found.")
+                    return
+                }
+
+                taskContext.delete(tempTarget)
+
+                guard taskContext.hasChanges else { return }
+                try taskContext.save()
+            } catch {
+                debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete Temp Target: \(error)")
+            }
+        }
     }
 
     func syncDate() -> Date {
@@ -252,13 +273,13 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     func getTempTargetsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate.lastActiveAdjustmentNotYetUploadedToNightscout,
             key: "date",
             ascending: false
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedTempTargets = results as? [TempTargetStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
@@ -289,7 +310,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
     func getTempTargetRunsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetRunStored.self,
-            onContext: backgroundContext,
+            onContext: context,
             predicate: NSPredicate(
                 format: "startDate >= %@ AND isUploadedToNS == %@",
                 Date.oneDayAgo as NSDate,
@@ -299,7 +320,7 @@ final class BaseTempTargetsStorage: TempTargetsStorage, Injectable {
             ascending: false
         )
 
-        return try await backgroundContext.perform {
+        return try await context.perform {
             guard let fetchedTempTargetRuns = results as? [TempTargetRunStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }

+ 5 - 1
Trio/Sources/Application/TrioApp.swift

@@ -63,9 +63,13 @@ import Swinject
     }
 
     init() {
+        let submodulesInfo = BuildDetails.default.submodules.map { key, value in
+            "\(key): \(value.branch) \(value.commitSHA)"
+        }.joined(separator: ", ")
+
         debug(
             .default,
-            "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.default.buildDate()))] [buildExpires: \(String(describing: BuildDetails.default.calculateExpirationDate()))]"
+            "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.default.buildDate()))] [buildExpires: \(String(describing: BuildDetails.default.calculateExpirationDate()))] [submodules: \(submodulesInfo)]"
         )
 
         // Setup up the Core Data Stack

+ 16 - 7
Trio/Sources/Helpers/BuildDetails.swift

@@ -1,9 +1,3 @@
-//
-//  BuildDetails.swift
-//  Trio
-//
-//  Created by Jonas Björkert on 2024-05-09.
-//
 import Foundation
 
 class BuildDetails {
@@ -14,7 +8,7 @@ class BuildDetails {
     init() {
         guard let url = Bundle.main.url(forResource: "BuildDetails", withExtension: "plist"),
               let data = try? Data(contentsOf: url),
-              let parsed = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]
+              let parsed = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any]
         else {
             dict = [:]
             return
@@ -32,6 +26,21 @@ class BuildDetails {
         return "\(branch) \(sha)"
     }
 
+    /// Returns a dictionary of submodule details.
+    /// The keys are the submodule names, and the values are tuples (branch, commitSHA).
+    var submodules: [String: (branch: String, commitSHA: String)] {
+        guard let subs = dict["com-trio-submodules"] as? [String: [String: Any]] else {
+            return [:]
+        }
+        var result = [String: (branch: String, commitSHA: String)]()
+        for (name, info) in subs {
+            let branch = info["branch"] as? String ?? String(localized: "Unknown")
+            let commitSHA = info["commit_sha"] as? String ?? String(localized: "Unknown")
+            result[name] = (branch: branch, commitSHA: commitSHA)
+        }
+        return result
+    }
+
     // Determine if the build is from TestFlight
     func isTestFlightBuild() -> Bool {
         #if targetEnvironment(simulator)

+ 159 - 4
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -12754,6 +12754,9 @@
         }
       }
     },
+    "±" : {
+
+    },
     "<  3.3 " : {
       "localizations" : {
         "ar" : {
@@ -26416,6 +26419,35 @@
         }
       }
     },
+    "Allow SMB for 6 hrs after a carb entry." : {
+
+    },
+    "Allow SMB when a manual Temporary Target is set greater than %@ %@." : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Allow SMB when a manual Temporary Target is set greater than %1$@ %2$@."
+          }
+        }
+      }
+    },
+    "Allow SMB when a manual Temporary Target is set under %@ %@." : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Allow SMB when a manual Temporary Target is set under %1$@ %2$@."
+          }
+        }
+      }
+    },
+    "Allow SMB when carbs are on board." : {
+
+    },
+    "Allow SMB when glucose is above the High BG Target value." : {
+
+    },
     "Allow SMB With High Temporary Target" : {
       "localizations" : {
         "ar" : {
@@ -26629,6 +26661,12 @@
         }
       }
     },
+    "Allow SMBs at all times except when a high Temp Target is set." : {
+
+    },
+    "Allow the creation of saved, preset meals." : {
+
+    },
     "Allow to add carbs in Trio." : {
       "localizations" : {
         "ar" : {
@@ -29272,6 +29310,9 @@
         }
       }
     },
+    "Amplitude:" : {
+
+    },
     "An example of a Carb Warning is 'Carbs required: 30 g'" : {
       "localizations" : {
         "ar" : {
@@ -40896,6 +40937,9 @@
         }
       }
     },
+    "Center Value:" : {
+
+    },
     "CGM" : {
       "comment" : "CGM",
       "localizations" : {
@@ -45378,6 +45422,9 @@
         }
       }
     },
+    "Configuration changes will take effect on the next glucose reading." : {
+
+    },
     "Configure Libre Transmitter" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -50619,6 +50666,16 @@
         }
       }
     },
+    "Decrease sensitivity when glucose is below target if a manual Temp Target < %@ %@ is set." : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Decrease sensitivity when glucose is below target if a manual Temp Target < %1$@ %2$@ is set."
+          }
+        }
+      }
+    },
     "Decreasing this setting may result in fewer FPU entries with larger carb values." : {
       "localizations" : {
         "ar" : {
@@ -50937,6 +50994,9 @@
         }
       }
     },
+    "Default impact of carb absorption over a 5 minute interval." : {
+
+    },
     "Default is 20 minutes. How often to update and save the statistics.json and to upload last array, when enabled, to Nightscout." : {
       "comment" : "Description for update interval for statistics",
       "extractionState" : "manual",
@@ -52987,6 +53047,9 @@
         }
       }
     },
+    "Default: 70%" : {
+
+    },
     "Default: 80%" : {
       "localizations" : {
         "ar" : {
@@ -59501,6 +59564,9 @@
         }
       }
     },
+    "Disables SMBs if last two glucose values differ by more than this percent." : {
+
+    },
     "Disabling this setting will still allow other commands, like Temp Targets, Add Carbs, and Start/End Overrides" : {
       "localizations" : {
         "ar" : {
@@ -67267,6 +67333,9 @@
         }
       }
     },
+    "Enable Unannounced Meals SMB." : {
+
+    },
     "Enable uploading of CGM readings to Nightscout." : {
 
     },
@@ -81901,6 +81970,9 @@
         }
       }
     },
+    "Glucose trace WILL NOT be affected by any insulin or carb entries." : {
+
+    },
     "Glucose Trend" : {
       "localizations" : {
         "ar" : {
@@ -90922,6 +90994,16 @@
     "Include IOB & COB in the calendar event data." : {
 
     },
+    "Increase sensitivity when glucose is above target if a manual Temp Target > %@ %@ is set." : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Increase sensitivity when glucose is above target if a manual Temp Target > %1$@ %2$@ is set."
+          }
+        }
+      }
+    },
     "Increase the safety threshold used to suspend insulin delivery." : {
 
     },
@@ -97479,6 +97561,12 @@
     "Limits temporary basal rates to this percentage of your largest basal rate." : {
       "comment" : "Mini Hint for Max Daily Safety Multiplier"
     },
+    "Limits the size of a single Super Micro Bolus (SMB) dose." : {
+
+    },
+    "Limits the size of a single Unannounced Meal (UAM) SMB dose." : {
+
+    },
     "Lines" : {
       "localizations" : {
         "ar" : {
@@ -98470,7 +98558,7 @@
         "de" : {
           "stringUnit" : {
             "state" : "translated",
-            "value" : "Holzkohle"
+            "value" : "Kohlenhydrate hinzufügen"
           }
         },
         "es" : {
@@ -101286,6 +101374,12 @@
         }
       }
     },
+    "Lower limit of the Autosens Ratio." : {
+
+    },
+    "Lower target glucose when Autosens Ratio is <1." : {
+
+    },
     "m" : {
       "comment" : "abbreviation for minutes",
       "localizations" : {
@@ -105741,6 +105835,9 @@
     "Maximum Meal Absorption Time" : {
 
     },
+    "Maximum units of insulin allowed to be active." : {
+
+    },
     "Meal" : {
       "comment" : "Debug option view Meal",
       "extractionState" : "manual",
@@ -108038,6 +108135,9 @@
         }
       }
     },
+    "Minimum minutes since the last SMB or manual bolus to allow an automated SMB." : {
+
+    },
     "Minimum Safety Threshold" : {
       "localizations" : {
         "ar" : {
@@ -112860,6 +112960,9 @@
     "No Temp Target Presets" : {
 
     },
+    "Noise:" : {
+
+    },
     "Noisy CGM Target Increase" : {
       "comment" : "Noisy CGM Target Increase",
       "localizations" : {
@@ -122038,6 +122141,9 @@
     "Percentage of bolus suggested in bolus calculator." : {
 
     },
+    "Percentage of calculated insulin required that is given as SMB." : {
+
+    },
     "Percentage of carbs still available if no absorption is detected." : {
       "comment" : "Mini Hint for Remaining Carbs Percentage"
     },
@@ -122154,6 +122260,9 @@
         }
       }
     },
+    "Period:" : {
+
+    },
     "Persist sensordata" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -124141,6 +124250,9 @@
     "Processing…" : {
 
     },
+    "Produce Stale Values" : {
+
+    },
     "profile" : {
       "localizations" : {
         "ar" : {
@@ -125682,6 +125794,9 @@
         }
       }
     },
+    "Pump rewind initiates a reset in Autosens Ratio." : {
+
+    },
     "Pump Settings" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -126646,6 +126761,22 @@
         }
       }
     },
+    "Raise target glucose if when Autosens Ratio is >1." : {
+
+    },
+    "Random variation added to each reading to simulate real-world sensor noise." : {
+
+    },
+    "Range: %@–%@ %@" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Range: %1$@–%2$@ %3$@"
+          }
+        }
+      }
+    },
     "Rapid-Acting: 75 minutes (permitted range 50-120 minutes)" : {
       "localizations" : {
         "ar" : {
@@ -129337,6 +129468,9 @@
         }
       }
     },
+    "Reset to Defaults" : {
+
+    },
     "Resistance Lowers Target" : {
       "comment" : "Resistance Lowers Target",
       "localizations" : {
@@ -132404,6 +132538,9 @@
         }
       }
     },
+    "Scales down your basal rate to 50% at this value." : {
+
+    },
     "Schedule" : {
       "localizations" : {
         "ar" : {
@@ -146675,6 +146812,9 @@
         }
       }
     },
+    "Submodules" : {
+
+    },
     "Subtract IOB" : {
       "localizations" : {
         "ar" : {
@@ -151243,6 +151383,9 @@
         }
       }
     },
+    "The average glucose level around which values will oscillate." : {
+
+    },
     "The current version has a critical issue and should be updated as soon as possible." : {
       "comment" : "Message for critical update alert"
     },
@@ -153388,6 +153531,9 @@
         }
       }
     },
+    "The maximum deviation from the center value. Higher values create wider swings." : {
+
+    },
     "The maximum duration for tracking carb entries in estimating Carbs on Board (COB)" : {
 
     },
@@ -154353,6 +154499,9 @@
         }
       }
     },
+    "The simulator creates a wave-like pattern that mimics natural glucose fluctuations throughout the day." : {
+
+    },
     "The source of the glucose reading will be added to the notification." : {
       "localizations" : {
         "ar" : {
@@ -154459,6 +154608,9 @@
         }
       }
     },
+    "The time it takes to complete one full cycle from high to low and back to high." : {
+
+    },
     "The Upload Treatments toggle enables uploading of the following data sets to your connected Nightscout URL:" : {
       "localizations" : {
         "ar" : {
@@ -168789,9 +168941,6 @@
         }
       }
     },
-    "Trio's glucose simulator does not offer any configuration. Its use is strictly for demonstration purposes only." : {
-
-    },
     "Trio's Simple Lock Screen Widget displays current glucose reading, trend arrow, delta and the timestamp of the current reading." : {
       "localizations" : {
         "ar" : {
@@ -171739,6 +171888,9 @@
         }
       }
     },
+    "Upper limit of the Autosens Ratio." : {
+
+    },
     "URL" : {
       "localizations" : {
         "ar" : {
@@ -176944,6 +177096,9 @@
         }
       }
     },
+    "When stale values are enabled, the simulator will repeatedly output the last generated glucose value." : {
+
+    },
     "When Suspend Zeros IOB is enabled, any active temporary basal rates during a pump suspension are reset, with new 0 U/hr temporary basal rates added to counteract those done during suspension." : {
       "localizations" : {
         "ar" : {

+ 1 - 0
Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift

@@ -366,6 +366,7 @@ extension Adjustments.StateModel {
     func invokeTempTargetPresetDeletion(_ objectID: NSManagedObjectID) async {
         await tempTargetStorage.deleteTempTargetPreset(objectID)
         setupTempTargetPresetsArray()
+        setupScheduledTempTargetsArray()
     }
 
     /// Resets Temp Target state variables.

+ 4 - 1
Trio/Sources/Modules/Adjustments/View/TempTargets/AddTempTargetForm.swift

@@ -252,8 +252,11 @@ struct AddTempTargetForm: View {
                             do {
                                 if noNameSpecified { state.tempTargetName = "Custom Target" }
                                 didPressSave.toggle()
-                                try await state.invokeSaveOfCustomTempTargets()
+
+                                /// We need to call dismiss() either before state.invokeSaveOfCustomTempTargets() or as a callback within the function BEFORE we await the Task, otherwise the sheet gets only closed when the scheduled Temp Target gets enacted
                                 dismiss()
+
+                                try await state.invokeSaveOfCustomTempTargets()
                             } catch {
                                 debug(.default, "\(DebuggingIdentifiers.failed) failed to save custom temp target: \(error)")
                             }

+ 1 - 1
Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift

@@ -264,7 +264,7 @@ extension AlgorithmAdvancedSettings {
                     units: state.units,
                     type: .decimal("min5mCarbimpact"),
                     label: String(localized: "Min 5m Carb Impact", comment: "Min 5m Carb Impact"),
-                    miniHint: "Default impact of carb absorption over a 5 minute interval.",
+                    miniHint: String(localized: "Default impact of carb absorption over a 5 minute interval."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text(

+ 4 - 4
Trio/Sources/Modules/AutosensSettings/View/AutosensSettingsRootView.swift

@@ -140,7 +140,7 @@ extension AutosensSettings {
                     units: state.units,
                     type: .decimal("autosensMax"),
                     label: String(localized: "Autosens Max", comment: "Autosens Max"),
-                    miniHint: "Upper limit of the Autosens Ratio.",
+                    miniHint: String(localized: "Upper limit of the Autosens Ratio."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 120%").bold()
@@ -171,10 +171,10 @@ extension AutosensSettings {
                     units: state.units,
                     type: .decimal("autosensMin"),
                     label: String(localized: "Autosens Min", comment: "Autosens Min"),
-                    miniHint: "Lower limit of the Autosens Ratio.",
+                    miniHint: String(localized: "Lower limit of the Autosens Ratio."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: 80%").bold()
+                        Text("Default: 70%").bold()
                         Text(
                             "Autosens Min sets the minimum Autosens Ratio used by Autosens, Dynamic ISF, and Sigmoid Formula."
                         )
@@ -201,7 +201,7 @@ extension AutosensSettings {
                     units: state.units,
                     type: .boolean,
                     label: String(localized: "Rewind Resets Autosens", comment: "Rewind Resets Autosens"),
-                    miniHint: "Pump rewind initiates a reset in Autosens Ratio.",
+                    miniHint: String(localized: "Pump rewind initiates a reset in Autosens Ratio."),
                     verboseHint: VStack(alignment: .leading, spacing: 5) {
                         Text("Default: ON").bold()
                         Text("Medtronic Users Only").bold()

+ 1 - 1
Trio/Sources/Modules/BolusCalculatorConfig/View/BolusCalculatorConfigRootView.swift

@@ -47,7 +47,7 @@ extension BolusCalculatorConfig {
                     units: state.units,
                     type: .boolean,
                     label: String(localized: "Display Meal Presets"),
-                    miniHint: "Allow the creation of saved, preset meals.",
+                    miniHint: String(localized: "Allow the creation of saved, preset meals."),
                     verboseHint: VStack(alignment: .leading, spacing: 10) {
                         Text("Default: ON").bold()
                         Text("Enabling this feature allows you to create and save preset meals.")

+ 257 - 36
Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift

@@ -15,14 +15,49 @@ extension CGMSettings {
 
         @State private var shouldDisplayDeletionConfirmation: Bool = false
 
+        // Simulator settings
+        @State private var centerValue: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_CenterValue")
+        @State private var amplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_Amplitude")
+        @State private var period: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_Period")
+        @State private var noiseAmplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_NoiseAmplitude")
+        @State private var produceStaleValues: Bool = UserDefaults.standard.bool(forKey: "GlucoseSimulator_ProduceStaleValues")
+
+        // Initialize state variables with defaults if needed
+        private func initializeSimulatorSettings() {
+            if centerValue == 0 {
+                centerValue = OscillatingGenerator.Defaults.centerValue
+            }
+            if amplitude == 0 {
+                amplitude = OscillatingGenerator.Defaults.amplitude
+            }
+            if period == 0 {
+                period = OscillatingGenerator.Defaults.period
+            }
+            if noiseAmplitude == 0 {
+                noiseAmplitude = OscillatingGenerator.Defaults.noiseAmplitude
+            }
+            // produceStaleValues is already initialized as false by default
+        }
+
+        // Save simulator settings to UserDefaults
+        private func saveSimulatorSettings() {
+            UserDefaults.standard.set(centerValue, forKey: "GlucoseSimulator_CenterValue")
+            UserDefaults.standard.set(amplitude, forKey: "GlucoseSimulator_Amplitude")
+            UserDefaults.standard.set(period, forKey: "GlucoseSimulator_Period")
+            UserDefaults.standard.set(noiseAmplitude, forKey: "GlucoseSimulator_NoiseAmplitude")
+            UserDefaults.standard.set(produceStaleValues, forKey: "GlucoseSimulator_ProduceStaleValues")
+        }
+
         var body: some View {
             NavigationView {
                 Form {
                     if cgmCurrent.type != .none {
                         if cgmCurrent.type == .nightscout {
                             nightscoutSection
-                        } else {
-                            customCGMSection
+                        } else if cgmCurrent.type == .xdrip {
+                            xDripConfigurationSection
+                        } else if cgmCurrent.type == .simulator {
+                            simulatorConfigurationSection
                         }
 
                         if let appURL = cgmCurrent.type.appURL {
@@ -64,7 +99,7 @@ extension CGMSettings {
                 }
                 .safeAreaInset(
                     edge: .bottom,
-                    spacing: 30
+                    spacing: 0
                 ) {
                     stickyDeleteButton
                 }
@@ -79,6 +114,11 @@ extension CGMSettings {
                             .tint(.red)
                     }
                 } message: { Text("Are you sure you want to delete \(cgmCurrent.displayName)?") }
+                .onAppear {
+                    if cgmCurrent.type == .simulator {
+                        initializeSimulatorSettings()
+                    }
+                }
             }
         }
 
@@ -95,7 +135,7 @@ extension CGMSettings {
                                     "To configure your CGM, tap the button below. In the form that opens, enter your Nightscout credentials to connect to your instance." :
                                     "Tap the button below to open your Nightscout instance in your iPhone's default browser."
                             ).font(.footnote)
-                                .foregroundColor(.secondary)
+                                .foregroundStyle(Color.secondary)
                                 .lineLimit(nil)
                                 .padding(.vertical)
                         }
@@ -136,61 +176,242 @@ extension CGMSettings {
             }
         }
 
-        var customCGMSection: some View {
+        var xDripConfigurationSection: some View {
             Section(
                 header: Text("Configuration"),
                 content: {
-                    if cgmCurrent.type == .xdrip {
-                        VStack(alignment: .leading) {
-                            if let cgmTransmitterDeviceAddress = UserDefaults.standard
-                                .cgmTransmitterDeviceAddress
-                            {
-                                Text("CGM address :").padding(.top)
-                                Text(cgmTransmitterDeviceAddress)
-                            } else {
-                                Text("CGM is not used as heartbeat.").padding(.top)
+                    VStack(alignment: .leading) {
+                        if let cgmTransmitterDeviceAddress = UserDefaults.standard
+                            .cgmTransmitterDeviceAddress
+                        {
+                            Text("CGM address :").padding(.top)
+                            Text(cgmTransmitterDeviceAddress)
+                        } else {
+                            Text("CGM is not used as heartbeat.").padding(.top)
+                        }
+
+                        HStack(alignment: .center) {
+                            Text(
+                                "A heartbeat tells Trio to start a loop cycle. This is required for closed loop."
+                            )
+                            .font(.footnote)
+                            .foregroundStyle(Color.secondary)
+                            .lineLimit(nil)
+                            Spacer()
+                        }.padding(.vertical)
+
+                        if let link = cgmCurrent.type.externalLink {
+                            Button {
+                                UIApplication.shared.open(link, options: [:], completionHandler: nil)
+                            } label: {
+                                HStack {
+                                    Text("About this source")
+                                    Spacer()
+                                    Image(systemName: "chevron.right")
+                                }
                             }
+                            .frame(maxWidth: .infinity, alignment: .leading)
+                        }
+                    }
+                }
+            ).listRowBackground(Color.chart)
+        }
 
-                            HStack(alignment: .center) {
-                                Text(
-                                    "A heartbeat tells Trio to start a loop cycle. This is required for closed loop."
-                                )
-                                .font(.footnote)
-                                .foregroundColor(.secondary)
-                                .lineLimit(nil)
-                                Spacer()
-                            }.padding(.vertical)
+        var simulatorConfigurationSection: some View {
+            Group {
+                Section(
+                    header: Text("Configuration"),
+                    content: {
+                        VStack(alignment: .leading, spacing: 12) {
+                            Text("CGM is not used as heartbeat.").lineLimit(nil)
+                                .padding(.top)
+
+                            Text("Glucose trace WILL NOT be affected by any insulin or carb entries.").lineLimit(nil)
+                                .bold()
+                        }
+
+                        VStack(alignment: .leading, spacing: 8) {
+                            Text(
+                                "The simulator creates a wave-like pattern that mimics natural glucose fluctuations throughout the day."
+                            ).lineLimit(nil)
+
+                            Text("Configuration changes will take effect on the next glucose reading.")
+                                .padding(.bottom).lineLimit(nil)
+                        }.foregroundStyle(Color.secondary).font(.footnote)
+                    }
+                ).listRowBackground(Color.chart)
+
+                Section {
+                    VStack(alignment: .leading, spacing: 10) {
+                        Toggle(isOn: $produceStaleValues) {
+                            VStack(alignment: .leading) {
+                                Text("Produce Stale Values")
+                            }
+                        }
+                        .padding(.top)
+                        .onChange(of: produceStaleValues) { _, newValue in
+                            UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_ProduceStaleValues")
                         }
-                    } else if cgmCurrent.type == .simulator {
+
                         Text(
-                            "Trio's glucose simulator does not offer any configuration. Its use is strictly for demonstration purposes only."
+                            "When stale values are enabled, the simulator will repeatedly output the last generated glucose value."
                         )
+                        .font(.footnote)
+                        .foregroundStyle(Color.secondary)
+                        .lineLimit(nil)
+                        .padding(.bottom)
                     }
+                }.listRowBackground(Color.chart)
 
-                    if let link = cgmCurrent.type.externalLink {
-                        Button {
-                            UIApplication.shared.open(link, options: [:], completionHandler: nil)
-                        } label: {
+                if !produceStaleValues {
+                    Section {
+                        VStack(alignment: .leading, spacing: 10) {
                             HStack {
-                                Text("About this source")
+                                Text("Center Value:").bold()
+
                                 Spacer()
-                                Image(systemName: "chevron.right")
-                            }
+
+                                Text(state.units == .mgdL ? centerValue.description : centerValue.formattedAsMmolL).bold()
+
+                                Text(state.units.rawValue).foregroundStyle(Color.secondary)
+                            }.padding(.top)
+
+                            Slider(value: $centerValue, in: 80 ... 200, step: 1)
+                                .accentColor(.accentColor)
+                                .onChange(of: centerValue) { _, newValue in
+                                    UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_CenterValue")
+                                }
+                                .padding(.vertical)
+
+                            Text("The average glucose level around which values will oscillate.")
+                                .font(.footnote)
+                                .foregroundStyle(Color.secondary)
+                                .lineLimit(nil)
+                                .padding(.bottom)
                         }
-                        .frame(maxWidth: .infinity, alignment: .leading)
-                    }
+                    }.listRowBackground(Color.chart)
+
+                    Section {
+                        VStack(alignment: .leading, spacing: 10) {
+                            HStack {
+                                Text("Amplitude:").bold()
+
+                                Spacer()
+
+                                Text("±")
+                                Text(state.units == .mgdL ? amplitude.description : amplitude.formattedAsMmolL).bold()
+
+                                Text(state.units.rawValue).foregroundStyle(Color.secondary)
+                            }.padding(.top)
+
+                            Slider(value: $amplitude, in: 10 ... 100, step: 5)
+                                .accentColor(.accentColor)
+                                .onChange(of: amplitude) { _, newValue in
+                                    UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_Amplitude")
+                                }
+                                .padding(.vertical)
+
+                            Text(
+                                "Range: \(state.units == .mgdL ? (centerValue - amplitude).description : (centerValue - amplitude).formattedAsMmolL)–\(state.units == .mgdL ? (centerValue + amplitude).description : (centerValue + amplitude).formattedAsMmolL) \(state.units.rawValue)"
+                            )
+                            .bold()
+                            .font(.footnote)
+                            .foregroundStyle(Color.secondary)
+                            .lineLimit(nil)
+
+                            Text("The maximum deviation from the center value. Higher values create wider swings.")
+                                .font(.footnote)
+                                .foregroundStyle(Color.secondary)
+                                .lineLimit(nil)
+                                .padding(.bottom)
+                        }
+                    }.listRowBackground(Color.chart)
+
+                    Section {
+                        VStack(alignment: .leading, spacing: 10) {
+                            HStack {
+                                Text("Period:").bold()
+
+                                Spacer()
+
+                                Text(Int(period / 3600).description).bold()
+
+                                Text("hours").foregroundStyle(Color.secondary)
+                            }.padding(.top)
+
+                            Slider(value: $period, in: 3600 ... 21600, step: 1800)
+                                .accentColor(.accentColor)
+                                .onChange(of: period) { _, newValue in
+                                    UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_Period")
+                                }
+                                .padding(.vertical)
+
+                            Text("The time it takes to complete one full cycle from high to low and back to high.")
+                                .font(.footnote)
+                                .foregroundStyle(Color.secondary)
+                                .lineLimit(nil)
+                                .padding(.bottom)
+                        }
+                    }.listRowBackground(Color.chart)
+
+                    Section {
+                        VStack(alignment: .leading, spacing: 10) {
+                            HStack {
+                                Text("Noise:").bold()
+
+                                Spacer()
+
+                                Text("±")
+
+                                Text(state.units == .mgdL ? noiseAmplitude.description : noiseAmplitude.formattedAsMmolL).bold()
+
+                                Text(state.units.rawValue).foregroundStyle(Color.secondary)
+                            }.padding(.top)
+
+                            Slider(value: $noiseAmplitude, in: 0 ... 20, step: 1)
+                                .accentColor(.accentColor)
+                                .onChange(of: noiseAmplitude) { _, newValue in
+                                    UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator_NoiseAmplitude")
+                                }
+                                .padding(.vertical)
+
+                            Text("Random variation added to each reading to simulate real-world sensor noise.")
+                                .font(.footnote)
+                                .foregroundStyle(Color.secondary)
+                                .lineLimit(nil)
+                                .padding(.bottom)
+                        }
+                    }.listRowBackground(Color.chart)
                 }
-            ).listRowBackground(Color.chart)
+
+                Section {
+                    Button(action: {
+                        centerValue = OscillatingGenerator.Defaults.centerValue
+                        amplitude = OscillatingGenerator.Defaults.amplitude
+                        period = OscillatingGenerator.Defaults.period
+                        noiseAmplitude = OscillatingGenerator.Defaults.noiseAmplitude
+                        produceStaleValues = OscillatingGenerator.Defaults.produceStaleValues
+                        saveSimulatorSettings()
+                    }, label: {
+                        Text("Reset to Defaults")
+
+                    })
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .tint(.white)
+                }.listRowBackground(Color.accentColor)
+
+            }.listSectionSpacing(sectionSpacing)
         }
 
         var stickyDeleteButton: some View {
             ZStack {
                 Rectangle()
-                    .frame(width: UIScreen.main.bounds.width, height: 65)
+                    .frame(width: UIScreen.main.bounds.width, height: 120)
                     .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white)
                     .background(.thinMaterial)
                     .opacity(0.8)
                     .clipShape(Rectangle())
+                    .padding(.bottom, -55)
 
                 Button(action: {
                     shouldDisplayDeletionConfirmation.toggle()

+ 23 - 7
Trio/Sources/Modules/DataTable/DataTableStateModel.swift

@@ -106,14 +106,21 @@ extension DataTable {
         func invokeCarbDeletionTask(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) {
             Task {
                 do {
-                    try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: isFpuOrComplexMeal)
-
+                    /// Set the variables that control the CustomProgressView BEFORE the actual deletion
+                    /// otherwise the determineBasalSync gets executed first, sets waitForSuggestion to false and afterwards waitForSuggestion is set in this function to true, leading to an endless animation
                     await MainActor.run {
                         carbEntryDeleted = true
                         waitForSuggestion = true
                     }
+
+                    try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: isFpuOrComplexMeal)
+
                 } catch {
                     debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete carbs: \(error.localizedDescription)")
+                    await MainActor.run {
+                        carbEntryDeleted = false
+                        waitForSuggestion = false
+                    }
                 }
             }
         }
@@ -214,11 +221,6 @@ extension DataTable {
             Task {
                 do {
                     try await invokeInsulinDeletion(treatmentObjectID)
-
-                    await MainActor.run {
-                        insulinEntryDeleted = true
-                        waitForSuggestion = true
-                    }
                 } catch {
                     debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete insulin entry: \(error)")
                 }
@@ -234,6 +236,16 @@ extension DataTable {
                     return
                 }
 
+                /// Set variables that control the CustomProgressView to true AFTER the authentication and BEFORE the actual determineBasalSync
+                /// We definitely need to set the variables BEFORE the actual sync
+                /// otherwise the determineBasalSync gets executed first, sets waitForSuggestion to false and afterwards waitForSuggestion is set in this function to true, leading to an endless animation
+                /// But we also want it AFTER the authentication
+                /// otherwise the animation would pop up even before the authentication prompt appears to the user
+                await MainActor.run {
+                    insulinEntryDeleted = true
+                    waitForSuggestion = true
+                }
+
                 // Delete from remote service(s) (i.e. Nightscout, Apple Health, Tidepool)
                 await deleteInsulinFromServices(with: treatmentObjectID)
 
@@ -246,6 +258,10 @@ extension DataTable {
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error.localizedDescription)"
                 )
+                await MainActor.run {
+                    insulinEntryDeleted = false
+                    waitForSuggestion = false
+                }
             }
         }
 

+ 1 - 1
Trio/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -180,7 +180,7 @@ extension DataTable {
                 } else {
                     ContentUnavailableView(
                         "No data.",
-                        systemImage: "injection.needle"
+                        systemImage: "syringe"
                     )
                 }
             }.listRowBackground(Color.chart)

+ 1 - 1
Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift

@@ -42,7 +42,7 @@ extension UnitsLimitsSettings {
                     units: state.units,
                     type: .decimal("maxIOB"),
                     label: String(localized: "Max IOB", comment: "Max IOB"),
-                    miniHint: "Maximum units of insulin allowed to be active.",
+                    miniHint: String(localized: "Maximum units of insulin allowed to be active."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 0 units").bold()

+ 1 - 1
Trio/Sources/Modules/Home/HomeStateModel+Setup/TempTargetSetup.swift

@@ -22,7 +22,7 @@ extension Home.StateModel {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: TempTargetStored.self,
             onContext: tempTargetFetchContext,
-            predicate: NSPredicate.lastActiveTempTarget,
+            predicate: NSPredicate.tempTargetsForMainChart,
             key: "date",
             ascending: false
         )

+ 16 - 12
Trio/Sources/Modules/SMBSettings/View/SMBSettingsRootView.swift

@@ -32,7 +32,7 @@ extension SMBSettings {
                     units: state.units,
                     type: .boolean,
                     label: String(localized: "Enable SMB Always", comment: "Enable SMB Always"),
-                    miniHint: "Allow SMBs at all times except when a high Temp Target is set.",
+                    miniHint: String(localized: "Allow SMBs at all times except when a high Temp Target is set."),
                     verboseHint: VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
                         Text(
@@ -60,7 +60,7 @@ extension SMBSettings {
                         units: state.units,
                         type: .boolean,
                         label: String(localized: "Enable SMB With COB", comment: "Enable SMB With COB"),
-                        miniHint: "Allow SMB when carbs are on board.",
+                        miniHint: String(localized: "Allow SMB when carbs are on board."),
                         verboseHint:
                         VStack(alignment: .leading, spacing: 10) {
                             Text("Default: OFF").bold()
@@ -87,7 +87,9 @@ extension SMBSettings {
                         units: state.units,
                         type: .boolean,
                         label: String(localized: "Enable SMB With Temptarget", comment: "Enable SMB With Temptarget"),
-                        miniHint: "Allow SMB when a manual Temporary Target is set under \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue).",
+                        miniHint: String(
+                            localized: "Allow SMB when a manual Temporary Target is set under \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue)."
+                        ),
                         verboseHint:
                         VStack(alignment: .leading, spacing: 10) {
                             Text("Default: OFF").bold()
@@ -114,7 +116,7 @@ extension SMBSettings {
                         units: state.units,
                         type: .boolean,
                         label: String(localized: "Enable SMB After Carbs", comment: "Enable SMB After Carbs"),
-                        miniHint: "Allow SMB for 6 hrs after a carb entry.",
+                        miniHint: String(localized: "Allow SMB for 6 hrs after a carb entry."),
                         verboseHint:
                         VStack(alignment: .leading, spacing: 10) {
                             Text("Default: OFF").bold()
@@ -142,7 +144,7 @@ extension SMBSettings {
                         type: .conditionalDecimal("enableSMB_high_bg_target"),
                         label: String(localized: "Enable SMB With High BG", comment: "Enable SMB With High BG"),
                         conditionalLabel: "High BG Target",
-                        miniHint: "Allow SMB when glucose is above the High BG Target value.",
+                        miniHint: String(localized: "Allow SMB when glucose is above the High BG Target value."),
                         verboseHint:
                         VStack(alignment: .leading, spacing: 10) {
                             Text("Default: OFF").bold()
@@ -178,7 +180,9 @@ extension SMBSettings {
                         "Allow SMB With High Temptarget",
                         comment: "Allow SMB With High Temptarget"
                     ),
-                    miniHint: "Allow SMB when a manual Temporary Target is set greater than \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue).",
+                    miniHint: String(
+                        localized: "Allow SMB when a manual Temporary Target is set greater than \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue)."
+                    ),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
@@ -208,7 +212,7 @@ extension SMBSettings {
                     units: state.units,
                     type: .boolean,
                     label: String(localized: "Enable UAM", comment: "Enable UAM"),
-                    miniHint: "Enable Unannounced Meals SMB.",
+                    miniHint: String(localized: "Enable Unannounced Meals SMB."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
@@ -238,7 +242,7 @@ extension SMBSettings {
                     units: state.units,
                     type: .decimal("maxSMBBasalMinutes"),
                     label: String(localized: "Max SMB Basal Minutes", comment: "Max SMB Basal Minutes"),
-                    miniHint: "Limits the size of a single Super Micro Bolus (SMB) dose.",
+                    miniHint: String(localized: "Limits the size of a single Super Micro Bolus (SMB) dose."),
                     verboseHint: VStack(spacing: 10) {
                         VStack(alignment: .leading, spacing: 10) {
                             VStack(alignment: .leading, spacing: 1) {
@@ -284,7 +288,7 @@ extension SMBSettings {
                     units: state.units,
                     type: .decimal("maxUAMSMBBasalMinutes"),
                     label: String(localized: "Max UAM Basal Minutes", comment: "Max UAM Basal Minutes"),
-                    miniHint: "Limits the size of a single Unannounced Meal (UAM) SMB dose.",
+                    miniHint: String(localized: "Limits the size of a single Unannounced Meal (UAM) SMB dose."),
                     verboseHint: VStack(spacing: 10) {
                         VStack(alignment: .leading, spacing: 10) {
                             VStack(alignment: .leading, spacing: 1) {
@@ -329,7 +333,7 @@ extension SMBSettings {
                     units: state.units,
                     type: .decimal("maxDeltaBGthreshold"),
                     label: String(localized: "Max Delta-BG Threshold SMB", comment: "Max Delta-BG Threshold"),
-                    miniHint: "Disables SMBs if last two glucose values differ by more than this percent.",
+                    miniHint: String(localized: "Disables SMBs if last two glucose values differ by more than this percent."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 20% increase").bold()
@@ -354,7 +358,7 @@ extension SMBSettings {
                     units: state.units,
                     type: .decimal("smbDeliveryRatio"),
                     label: String(localized: "SMB Delivery Ratio", comment: "SMB Delivery Ratio"),
-                    miniHint: "Percentage of calculated insulin required that is given as SMB.",
+                    miniHint: String(localized: "Percentage of calculated insulin required that is given as SMB."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 50%").bold()
@@ -382,7 +386,7 @@ extension SMBSettings {
                     units: state.units,
                     type: .decimal("smbInterval"),
                     label: String(localized: "SMB Interval", comment: "SMB Interval"),
-                    miniHint: "Minimum minutes since the last SMB or manual bolus to allow an automated SMB.",
+                    miniHint: String(localized: "Minimum minutes since the last SMB or manual bolus to allow an automated SMB."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: 3 min").bold()

+ 1 - 1
Trio/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -80,7 +80,7 @@ extension Settings {
                             let versionNumber = Bundle.main.releaseVersionNumber ?? String(localized: "Unknown")
                             let buildNumber = Bundle.main.buildVersionNumber ?? String(localized: "Unknown")
 
-                            Group {
+                            NavigationLink(destination: SubmodulesView(buildDetails: buildDetails)) {
                                 HStack {
                                     Image(appIcons.appIcon.rawValue)
                                         .resizable()

+ 34 - 0
Trio/Sources/Modules/Settings/View/Subviews/SubmodulesView.swift

@@ -0,0 +1,34 @@
+import SwiftUI
+
+struct SubmodulesView: View {
+    let buildDetails: BuildDetails
+
+    var body: some View {
+        List {
+            Section {
+                ForEach(buildDetails.submodules.sorted(by: { $0.key < $1.key }), id: \.key) { name, info in
+                    KeyValueRow(key: name, value: info.commitSHA)
+                }
+            }
+        }
+        .listStyle(.insetGrouped)
+        .navigationTitle("Submodules")
+        .navigationBarTitleDisplayMode(.inline)
+    }
+}
+
+struct KeyValueRow: View {
+    let key: String
+    let value: String
+
+    var body: some View {
+        HStack {
+            Text(key)
+                .foregroundColor(.primary)
+            Spacer()
+            Text(value)
+                .foregroundColor(.secondary)
+                .multilineTextAlignment(.trailing)
+        }
+    }
+}

+ 10 - 6
Trio/Sources/Modules/TargetBehavoir/View/TargetBehavoirRootView.swift

@@ -40,12 +40,14 @@ extension TargetBehavoir {
                         "High Temp Target Raises Sensitivity",
                         comment: "High Temp Target Raises Sensitivity"
                     ),
-                    miniHint: "Increase sensitivity when glucose is above target if a manual Temp Target > \(state.units == .mgdL ? "110" : 110.formattedAsMmolL) \(state.units.rawValue) is set.",
+                    miniHint: String(
+                        localized: "Increase sensitivity when glucose is above target if a manual Temp Target > \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue) is set."
+                    ),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
                         Text(
-                            "When this feature is enabled, manually setting a temporary target above \(state.units == .mgdL ? "110" : 110.formattedAsMmolL) \(state.units.rawValue) will decrease the Autosens Ratio used for ISF and basal adjustments, resulting in less insulin delivered overall. This scales with the temporary target set; the higher the temp target, the lower the Autosens Ratio used."
+                            "When this feature is enabled, manually setting a temporary target above \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue) will decrease the Autosens Ratio used for ISF and basal adjustments, resulting in less insulin delivered overall. This scales with the temporary target set; the higher the temp target, the lower the Autosens Ratio used."
                         )
                         Text(
                             "If Half Basal Exercise Target is set to \(state.units == .mgdL ? "160" : 160.formattedAsMmolL) \(state.units.rawValue), a temp target of \(state.units == .mgdL ? "120" : 120.formattedAsMmolL) \(state.units.rawValue) uses an Autosens Ratio of 0.75. A temp target of \(state.units == .mgdL ? "140" : 140.formattedAsMmolL) \(state.units.rawValue) uses an Autosens Ratio of 0.6."
@@ -77,7 +79,9 @@ extension TargetBehavoir {
                         "Low Temp Target Lowers Sensitivity",
                         comment: "Low Temp Target Lowers Sensitivity"
                     ),
-                    miniHint: "Decrease sensitivity when glucose is below target if a manual Temp Target < \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue) is set.",
+                    miniHint: String(
+                        localized: "Decrease sensitivity when glucose is below target if a manual Temp Target < \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue) is set."
+                    ),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
@@ -105,7 +109,7 @@ extension TargetBehavoir {
                     units: state.units,
                     type: .boolean,
                     label: String(localized: "Sensitivity Raises Target", comment: "Sensitivity Raises Target"),
-                    miniHint: "Raise target glucose if when Autosens Ratio is >1.",
+                    miniHint: String(localized: "Raise target glucose if when Autosens Ratio is >1."),
                     verboseHint: VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
                         Text(
@@ -128,7 +132,7 @@ extension TargetBehavoir {
                     units: state.units,
                     type: .boolean,
                     label: String(localized: "Resistance Lowers Target", comment: "Resistance Lowers Target"),
-                    miniHint: "Lower target glucose when Autosens Ratio is <1.",
+                    miniHint: String(localized: "Lower target glucose when Autosens Ratio is <1."),
                     verboseHint: VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
                         Text(
@@ -151,7 +155,7 @@ extension TargetBehavoir {
                     units: state.units,
                     type: .decimal("halfBasalExerciseTarget"),
                     label: String(localized: "Half Basal Exercise Target", comment: "Half Basal Exercise Target"),
-                    miniHint: "Scales down your basal rate to 50% at this value.",
+                    miniHint: String(localized: "Scales down your basal rate to 50% at this value."),
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text(

+ 60 - 35
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -80,15 +80,16 @@ extension Treatments {
             HStack {
                 HStack {
                     Text("Protein")
-
                     TextFieldWithToolBar(
                         text: $state.protein,
                         placeholder: "0",
                         keyboardType: .numberPad,
                         numberFormatter: mealFormatter,
-                        previousTextField: { focusOnPreviousTextField(index: 2) },
-                        nextTextField: { focusOnNextTextField(index: 2) }
-                    ).focused($focusedField, equals: .protein)
+                        showArrows: true,
+                        previousTextField: { focusedField = previousField(from: .protein) },
+                        nextTextField: { focusedField = nextField(from: .protein) }
+                    )
+                    .focused($focusedField, equals: .protein)
                     Text("g").foregroundColor(.secondary)
                 }
 
@@ -101,9 +102,11 @@ extension Treatments {
                         placeholder: "0",
                         keyboardType: .numberPad,
                         numberFormatter: mealFormatter,
-                        previousTextField: { focusOnPreviousTextField(index: 3) },
-                        nextTextField: { focusOnNextTextField(index: 3) }
-                    ).focused($focusedField, equals: .fat)
+                        showArrows: true,
+                        previousTextField: { focusedField = previousField(from: .fat) },
+                        nextTextField: { focusedField = nextField(from: .fat) }
+                    )
+                    .focused($focusedField, equals: .fat)
                     Text("g").foregroundColor(.secondary)
                 }
             }
@@ -118,39 +121,60 @@ extension Treatments {
                     placeholder: "0",
                     keyboardType: .numberPad,
                     numberFormatter: mealFormatter,
-                    previousTextField: { focusOnPreviousTextField(index: 1) },
-                    nextTextField: { focusOnNextTextField(index: 1) }
-                ).focused($focusedField, equals: .carbs)
-                    .onChange(of: state.carbs) {
-                        handleDebouncedInput()
-                    }
+                    showArrows: true,
+                    previousTextField: { focusedField = previousField(from: .carbs) },
+                    nextTextField: { focusedField = nextField(from: .carbs) }
+                )
+                .focused($focusedField, equals: .carbs)
+                .onChange(of: state.carbs) {
+                    handleDebouncedInput()
+                }
                 Text("g").foregroundColor(.secondary)
             }
         }
 
-        func focusOnPreviousTextField(index: Int) {
-            switch index {
-            case 2:
-                focusedField = .carbs
-            case 3:
-                focusedField = .fat
-            case 4:
-                focusedField = .protein
-            default:
-                break
+        /// Determines the next field to focus on based on the current focused field.
+        ///
+        /// This function handles the tab order navigation between input fields,
+        /// taking into account whether fat/protein fields are visible based on user settings.
+        ///
+        /// - Parameter current: The currently focused field
+        /// - Returns: The next field that should receive focus, or nil if there is no next field
+        private func nextField(from current: FocusedField) -> FocusedField? {
+            // If fat/protein fields are hidden, skip them in navigation
+            let showFPU = state.useFPUconversion
+
+            switch current {
+            case .fat:
+                return .bolus
+            case .protein:
+                return .fat
+            case .carbs:
+                return showFPU ? .protein : .bolus
+            case .bolus:
+                return .carbs
             }
         }
 
-        func focusOnNextTextField(index: Int) {
-            switch index {
-            case 1:
-                focusedField = .fat
-            case 2:
-                focusedField = .protein
-            case 3:
-                focusedField = .bolus
-            default:
-                break
+        /// Determines the previous field to focus on based on the current focused field.
+        ///
+        /// This function handles the reverse tab order navigation between input fields,
+        /// taking into account whether fat/protein fields are visible based on user settings.
+        ///
+        /// - Parameter current: The currently focused field
+        /// - Returns: The previous field that should receive focus, or nil if there is no previous field
+        private func previousField(from current: FocusedField) -> FocusedField? {
+            let showFPU = state.useFPUconversion
+
+            switch current {
+            case .fat:
+                return .protein
+            case .protein:
+                return .carbs
+            case .carbs:
+                return .bolus
+            case .bolus:
+                return showFPU ? .fat : .carbs
             }
         }
 
@@ -287,8 +311,9 @@ extension Treatments {
                                     textColor: colorScheme == .dark ? .white : .blue,
                                     maxLength: 5,
                                     numberFormatter: formatter,
-                                    previousTextField: { focusOnPreviousTextField(index: 4) },
-                                    nextTextField: { focusOnNextTextField(index: 4) }
+                                    showArrows: true,
+                                    previousTextField: { focusedField = previousField(from: .bolus) },
+                                    nextTextField: { focusedField = nextField(from: .bolus) }
                                 ).focused($focusedField, equals: .bolus)
                                     .onChange(of: state.amount) {
                                         Task {

+ 19 - 8
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift

@@ -41,15 +41,26 @@ extension TrioRemoteControl {
         }
 
         let pushMessageDate = Date(timeIntervalSince1970: pushMessage.timestamp)
-        let recentCarbEntries = carbsStorage.recent()
-        let carbsAfterPushMessage = recentCarbEntries.filter { $0.createdAt > pushMessageDate }
+        let taskContext = CoreDataStack.shared.newTaskContext()
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: taskContext,
+            predicate: NSPredicate(format: "createdAt > %@", pushMessageDate as NSDate),
+            key: "createdAt",
+            ascending: false
+        )
 
-        if !carbsAfterPushMessage.isEmpty {
-            await logError(
-                "Command rejected: newer carb entries have been logged since the command was sent.",
-                pushMessage: pushMessage
-            )
-            return
+        await taskContext.perform {
+            guard let recentCarbEntries = results as? [CarbEntryStored] else { return }
+            if !recentCarbEntries.isEmpty {
+                Task {
+                    await self.logError(
+                        "Command rejected: newer carb entries have been logged since the command was sent.",
+                        pushMessage: pushMessage
+                    )
+                    return
+                }
+            }
         }
 
         let actualDate: Date?

+ 19 - 7
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -246,7 +246,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                     }
 
                     let iobValue = latestDetermination.iob ?? 0
-                    watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iobValue)
+                    watchState.iob = self.iobFormatterWithOneFractionDigit(iobValue as Decimal)
 
                     let cobNumber = NSNumber(value: latestDetermination.cob)
                     watchState.cob = Formatter.integerFormatter.string(from: cobNumber)
@@ -256,15 +256,13 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
 
                     if self.units == .mgdL {
                         watchState.isf = insulinSensitivity.description
-                        watchState.eventualBGRaw = Formatter.glucoseFormatter(for: self.units)
-                            .string(from: eventualBG) ?? "0"
+                        watchState.eventualBGRaw = eventualBG.description
                     } else {
                         let parsedIsf = Double(truncating: insulinSensitivity).asMmolL
                         let parsedEventualBG = Double(truncating: eventualBG).asMmolL
 
                         watchState.isf = parsedIsf.description
-                        watchState.eventualBGRaw = Formatter.glucoseFormatter(for: self.units)
-                            .string(from: parsedEventualBG as NSNumber) ?? "0"
+                        watchState.eventualBGRaw = parsedEventualBG.description
                     }
                 }
 
@@ -293,8 +291,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                         deltaValue = Double(truncating: deltaValue as NSNumber).asMmolL
                     }
 
-                    let formattedDelta = Formatter.glucoseFormatter(for: self.units)
-                        .string(from: deltaValue as NSNumber) ?? "0"
+                    let formattedDelta = deltaValue.description
                     watchState.delta = deltaValue < 0 ? "\(formattedDelta)" : "+\(formattedDelta)"
                 }
 
@@ -493,6 +490,21 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
             }
         )
     }
+
+    func iobFormatterWithOneFractionDigit(_ value: Decimal) -> String {
+        let formatter = NumberFormatter()
+        formatter.numberStyle = .decimal
+        formatter.decimalSeparator = "."
+        formatter.maximumFractionDigits = 1
+        formatter.minimumFractionDigits = 1
+
+        // Prevent small values from rounding to 0 by enforcing a minimum threshold
+        if value.magnitude < 0.1, value != 0 {
+            return value > 0 ? "0.1" : "-0.1"
+        }
+
+        return formatter.string(from: value as NSNumber) ?? "\(value)"
+    }
 }
 
 // MARK: - Extensions

+ 9 - 0
Trio/Sources/Shortcuts/AppShortcuts.swift

@@ -57,5 +57,14 @@ struct AppShortcuts: AppShortcutsProvider {
             shortTitle: "Cancel Override",
             systemImageName: "xmark.circle.fill"
         )
+        AppShortcut(
+            intent: CancelTempPresetIntent(),
+            phrases: [
+                "Cancel \(.applicationName) temporary target",
+                "Cancels an active \(.applicationName) temporary target"
+            ],
+            shortTitle: "Cancel Temp Target",
+            systemImageName: "xmark.circle.fill"
+        )
     }
 }

+ 115 - 65
Trio/Sources/Views/TextFieldWithToolBar.swift

@@ -15,6 +15,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
     var textFieldDidBeginEditing: (() -> Void)?
     var numberFormatter: NumberFormatter
     var allowDecimalSeparator: Bool
+    var showArrows: Bool
     var previousTextField: (() -> Void)?
     var nextTextField: (() -> Void)?
 
@@ -32,6 +33,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
         textFieldDidBeginEditing: (() -> Void)? = nil,
         numberFormatter: NumberFormatter,
         allowDecimalSeparator: Bool = true,
+        showArrows: Bool = false,
         previousTextField: (() -> Void)? = nil,
         nextTextField: (() -> Void)? = nil
     ) {
@@ -49,6 +51,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
         self.numberFormatter = numberFormatter
         self.numberFormatter.numberStyle = .decimal
         self.allowDecimalSeparator = allowDecimalSeparator
+        self.showArrows = showArrows
         self.previousTextField = previousTextField
         self.nextTextField = nextTextField
     }
@@ -56,7 +59,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
     public func makeUIView(context: Context) -> UITextField {
         let textField = UITextField()
         context.coordinator.textField = textField
-        textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
+        textField.inputAccessoryView = isDismissible ? createToolbar(for: textField, context: context) : nil
         textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
         textField.delegate = context.coordinator
         if text == 0 { /// show no value initially, i.e. empty String
@@ -68,36 +71,63 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
         return textField
     }
 
-    private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
-        let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
-        let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
-        let doneButton = UIBarButtonItem(
-            image: UIImage(systemName: "keyboard.chevron.compact.down"),
-            style: .done,
-            target: textField,
-            action: #selector(UITextField.resignFirstResponder)
-        )
-        let clearButton = UIBarButtonItem(
-            image: UIImage(systemName: "trash"),
-            style: .plain,
-            target: context.coordinator,
-            action: #selector(Coordinator.clearText)
-        )
-        let previousButton = UIBarButtonItem(
-            image: UIImage(systemName: "chevron.up"),
-            style: .plain,
-            target: context.coordinator,
-            action: #selector(Coordinator.previousTextField)
-        )
-        let nextButton = UIBarButtonItem(
-            image: UIImage(systemName: "chevron.down"),
-            style: .plain,
-            target: context.coordinator,
-            action: #selector(Coordinator.nextTextField)
+    /// Creates and configures a toolbar for the text field with navigation and action buttons.
+    /// - Parameters:
+    ///   - _: The text field for which the toolbar is being created (unused parameter).
+    ///   - context: The SwiftUI context that contains the coordinator for handling button actions.
+    /// - Returns: A configured UIToolbar with appropriate buttons based on the view's configuration.
+    private func createToolbar(for _: UITextField, context: Context) -> UIToolbar {
+        let toolbar = UIToolbar()
+        var items: [UIBarButtonItem] = []
+
+        // Add navigation arrows if enabled
+        if showArrows {
+            // Add clear button
+            items.append(
+                UIBarButtonItem(
+                    image: UIImage(systemName: "trash"),
+                    style: .plain,
+                    target: context.coordinator,
+                    action: #selector(Coordinator.clearText)
+                )
+            )
+
+            if previousTextField != nil {
+                let previousButton = UIBarButtonItem(
+                    image: UIImage(systemName: "chevron.up"),
+                    style: .plain,
+                    target: context.coordinator,
+                    action: #selector(Coordinator.previousTextField)
+                )
+                items.append(previousButton)
+            }
+
+            if nextTextField != nil {
+                let nextButton = UIBarButtonItem(
+                    image: UIImage(systemName: "chevron.down"),
+                    style: .plain,
+                    target: context.coordinator,
+                    action: #selector(Coordinator.nextTextField)
+                )
+                items.append(nextButton)
+            }
+        }
+
+        // Add flexible space
+        items.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))
+
+        // Add done button
+        items.append(
+            UIBarButtonItem(
+                barButtonSystemItem: .done,
+                target: UIApplication.shared,
+                action: #selector(UIApplication.endEditing)
+            )
         )
 
-        toolbar.items = [clearButton, previousButton, nextButton, flexibleSpace, doneButton]
+        toolbar.items = items
         toolbar.sizeToFit()
+
         return toolbar
     }
 
@@ -185,6 +215,16 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
 }
 
 extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
+    public func textFieldDidEndEditing(_ textField: UITextField) {
+        if let text = textField.text,
+           let decimal = Decimal(string: text, locale: parent.numberFormatter.locale)
+        {
+            // Format the number properly when editing ends
+            textField.text = parent.numberFormatter.string(from: decimal as NSNumber)
+            parent.text = decimal
+        }
+    }
+
     public func textField(
         _ textField: UITextField,
         shouldChangeCharactersIn range: NSRange,
@@ -192,52 +232,57 @@ extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
     ) -> Bool {
         // Check if the input is a number or the decimal separator
         let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
-        let isDecimalSeparator = (string == decimalFormatter.decimalSeparator && textField.text?.contains(string) == false)
 
-        // Only proceed if the input is a valid number or decimal separator
-        if isNumber || isDecimalSeparator && parent.allowDecimalSeparator,
+        // Get the current locale's decimal separator
+        let currentDecimalSeparator = parent.numberFormatter.decimalSeparator ?? "."
+
+        // Check if input is a decimal separator (either . or ,)
+        let isInputDecimalSeparator = string == "." || string == ","
+
+        // Only allow the decimal separator configured in the locale
+        if isInputDecimalSeparator {
+            // If it's not the correct decimal separator for this locale, reject it
+            if string != currentDecimalSeparator {
+                return false
+            }
+            // Check if the field already contains a decimal separator
+            if textField.text?.contains(currentDecimalSeparator) == true {
+                return false
+            }
+        }
+
+        // Only proceed if the input is a valid number or the correct decimal separator
+        if isNumber || (string == currentDecimalSeparator && parent.allowDecimalSeparator),
            let currentText = textField.text as NSString?
         {
-            // Get the proposed new text
-            let proposedTextOriginal = currentText.replacingCharacters(in: range, with: string)
-
-            // Remove thousand separator
-            let proposedText = proposedTextOriginal.replacingOccurrences(of: decimalFormatter.groupingSeparator, with: "")
+            // Calculate the new text length
+            let newLength = currentText.length + string.count - range.length
 
-            // Try to convert proposed text to number
-            let number = parent.numberFormatter.number(from: proposedText) ?? decimalFormatter.number(from: proposedText)
+            // Check max length if specified
+            if let maxLength = parent.maxLength, newLength > maxLength {
+                return false
+            }
 
-            let decimalPlacesCurrent = calculateDecimalPlaces(in: currentText as String)
-            let maxDecimalPlaces = parent.numberFormatter.maximumFractionDigits
-            let isCursorAfterDecimal = isCursorAfterDecimal(in: textField, range: range)
+            // Create the new text string
+            let newText = currentText.replacingCharacters(in: range, with: string)
 
-            if decimalPlacesCurrent >= maxDecimalPlaces,
-               range.length == 0,
-               isCursorAfterDecimal
-            {
+            // If text starts with decimal separator, add leading zero
+            if newText.hasPrefix(currentDecimalSeparator) {
+                textField.text = "0" + newText
+                parent.text = Decimal(string: textField.text ?? "0") ?? 0
                 return false
             }
 
-            // Update the binding value if conversion is successful
-            if let number = number {
-                let lastCharIndex = proposedText.index(before: proposedText.endIndex)
-                let hasDecimalSeparator = proposedText.contains(decimalFormatter.decimalSeparator)
-                let hasTrailingZeros = (hasDecimalSeparator && proposedText[lastCharIndex] == "0") || isDecimalSeparator
-                if !hasTrailingZeros
-                {
-                    DispatchQueue.main.async {
-                        self.parent.text = number.decimalValue
-                    }
-                }
-            } else {
-                DispatchQueue.main.async {
-                    self.parent.text = 0
-                }
+            // Update the binding
+            if let decimal = Decimal(string: newText, locale: parent.numberFormatter.locale) {
+                parent.text = decimal
             }
+
+            return true
         }
 
-        // Allow the change if it's a valid number or decimal separator
-        return isNumber || isDecimalSeparator && parent.allowDecimalSeparator
+        // Allow the change if it's a valid number or the correct decimal separator
+        return isNumber || (string == currentDecimalSeparator && parent.allowDecimalSeparator)
     }
 
     public func textFieldDidBeginEditing(_: UITextField) {
@@ -254,7 +299,7 @@ extension UITextField {
 }
 
 extension UIApplication {
-    func endEditing() {
+    @objc func endEditing() {
         sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
     }
 }
@@ -273,7 +318,7 @@ public struct TextFieldWithToolBarString: UIViewRepresentable {
     public func makeUIView(context: Context) -> UITextField {
         let textField = UITextField()
         context.coordinator.textField = textField
-        textField.inputAccessoryView = isDismissible ? makeDoneToolbar(for: textField, context: context) : nil
+        textField.inputAccessoryView = isDismissible ? createToolbar(for: textField, context: context) : nil
         textField.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
         textField.delegate = context.coordinator
         textField.text = text
@@ -286,7 +331,12 @@ public struct TextFieldWithToolBarString: UIViewRepresentable {
         return textField
     }
 
-    private func makeDoneToolbar(for textField: UITextField, context: Context) -> UIToolbar {
+    /// Creates and configures a toolbar for the text field with clear and dismiss buttons.
+    /// - Parameters:
+    ///   - textField: The text field for which the toolbar is being created.
+    ///   - context: The SwiftUI context that contains the coordinator for handling button actions.
+    /// - Returns: A configured UIToolbar with clear and dismiss buttons.
+    private func createToolbar(for textField: UITextField, context: Context) -> UIToolbar {
         let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 50))
         let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
         let doneButton = UIBarButtonItem(

+ 590 - 0
TrioTests/BolusCalculatorTests/BolusCalculatorTests.swift

@@ -0,0 +1,590 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Bolus Calculator Tests") struct BolusCalculatorTests: Injectable {
+    @Injected() var calculator: BolusCalculationManager!
+    @Injected() var settingsManager: SettingsManager!
+    @Injected() var fileStorage: FileStorage!
+    @Injected() var apsManager: APSManager!
+    let resolver = TrioApp().resolver
+
+    init() {
+        injectServices(resolver)
+    }
+
+    @Test("Calculator is correctly initialized") func testCalculatorInitialization() {
+        #expect(calculator != nil, "BolusCalculationManager should be injected")
+        #expect(calculator is BaseBolusCalculationManager, "Calculator should be of type BaseBolusCalculationManager")
+    }
+
+    @Test("Calculate insulin for standard meal") func testStandardMealCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = false
+        let useSuperBolus: Bool = false
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+
+        // STEP 2: Create calculation input
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+
+        // STEP 3: Calculate insulin
+        let result = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Verify results
+        // Expected calculation breakdown:
+        // wholeCob = 80g + 20g COB = 100g
+        // wholeCobInsulin = 100g ÷ 10 g/U = 10U
+        // targetDifference = currentBG - target = 180 - 100 = 80 mg/dL
+        // targetDifferenceInsulin = 80 mg/dL ÷ 40 mg/dL/U = 2U
+        // fifteenMinutesInsulin = 5 mg/dL ÷ 40 mg/dL/U = 0.125U
+        // correctionInsulin = targetDifferenceInsulin = 2U
+        // iobInsulinReduction = 1U
+        // superBolusInsulin = 0U (disabled)
+        // no adjustment for fatty meals (disabled)
+        // wholeCalc = round(wholeCobInsulin + correctionInsulin + fifteenMinutesInsulin - iobInsulinReduction, 3) = 11.125U
+        // insulinCalculated = round(wholeCalc × fraction, 3) = 8.9U
+
+        // Calculate expected values with proper rounding using roundBolus method from the apsManager
+        let wholeCobInsulin = apsManager.roundBolus(amount: Decimal(100) / Decimal(10)) // 10U
+        let targetDifferenceInsulin = apsManager.roundBolus(amount: Decimal(80) / Decimal(40)) // 2U
+        let fifteenMinutesInsulin = apsManager.roundBolus(amount: Decimal(5) / Decimal(40)) // 0.125U
+        let wholeCalc = wholeCobInsulin + targetDifferenceInsulin + fifteenMinutesInsulin - Decimal(1) // 11.125U
+        let expectedInsulinCalculated = apsManager.roundBolus(amount: wholeCalc * fraction) // 8.9U
+
+        #expect(
+            result.insulinCalculated == expectedInsulinCalculated,
+            """
+            Incorrect insulin calculation
+            Expected: \(expectedInsulinCalculated)U
+            Actual: \(result.insulinCalculated)U
+            Components from CalculationResult:
+            - insulinCalculated: \(result.insulinCalculated)U (expected: \(expectedInsulinCalculated)U)
+            - wholeCalc: \(result.wholeCalc)U (expected: \(wholeCalc)U)
+            - correctionInsulin: \(result.correctionInsulin)U (expected: \(targetDifferenceInsulin)U)
+            - iobInsulinReduction: \(result.iobInsulinReduction)U (expected: 1U)
+            - superBolusInsulin: \(result.superBolusInsulin)U (expected: 0U)
+            - targetDifference: \(result.targetDifference) mg/dL (expected: 80 mg/dL)
+            - targetDifferenceInsulin: \(result.targetDifferenceInsulin)U (expected: \(targetDifferenceInsulin)U)
+            - fifteenMinutesInsulin: \(result.fifteenMinutesInsulin)U (expected: \(fifteenMinutesInsulin)U)
+            - wholeCob: \(result.wholeCob)g (expected: 100g)
+            - wholeCobInsulin: \(result.wholeCobInsulin)U (expected: \(wholeCobInsulin)U)
+            """
+        )
+
+        // Verify each component from CalculationResult struct with rounded values
+        #expect(
+            result.insulinCalculated == expectedInsulinCalculated,
+            "Final calculated insulin amount should be \(expectedInsulinCalculated)U"
+        )
+        #expect(result.wholeCalc == wholeCalc, "Total calculation before fraction should be \(wholeCalc)U")
+        #expect(
+            result.correctionInsulin == targetDifferenceInsulin,
+            "Insulin for BG correction should be \(targetDifferenceInsulin)U"
+        )
+        #expect(result.iobInsulinReduction == -1.0, "Absolute IOB reduction amount should be 1U, hence -1U")
+        #expect(result.superBolusInsulin == 0, "Additional insulin for super bolus should be 0U")
+        #expect(result.targetDifference == 80, "Difference from target BG should be 80 mg/dL")
+        #expect(
+            result.targetDifferenceInsulin == targetDifferenceInsulin,
+            "Insulin needed for target difference should be \(targetDifferenceInsulin)U"
+        )
+        #expect(
+            result.fifteenMinutesInsulin == fifteenMinutesInsulin,
+            "Trend-based insulin adjustment should be \(fifteenMinutesInsulin)U"
+        )
+        #expect(result.wholeCob == 100, "Total carbs (COB + new carbs) should be 100g")
+        #expect(result.wholeCobInsulin == wholeCobInsulin, "Insulin for total carbs should be \(wholeCobInsulin)U")
+    }
+
+    @Test("Calculate insulin for fatty meal") func testFattyMealCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = true // now set to true
+        let useSuperBolus: Bool = false
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+
+        // STEP 2: Create calculation input
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+
+        // STEP 3: Calculate insulin with fatty meal enabled
+        let fattyMealResult = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Calculate insulin with fatty meal disabled for comparison
+        let standardInput = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: false, // Disabled for comparison
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+        let standardResult = await calculator.calculateInsulin(input: standardInput)
+
+        // STEP 5: Verify results
+        // Fatty meal should reduce the insulin amount by the fatty meal factor (0.8)
+        let expectedReduction = fattyMealFactor
+        let actualReduction = Decimal(
+            (Double(fattyMealResult.insulinCalculated) / Double(standardResult.insulinCalculated) * 10.0).rounded() / 10.0
+        )
+
+        #expect(
+            actualReduction == expectedReduction,
+            """
+            Fatty meal calculation incorrect
+            Expected reduction factor: \(expectedReduction)
+            Actual reduction factor: \(actualReduction)
+            Standard calculation: \(standardResult.insulinCalculated)U
+            Fatty meal calculation: \(fattyMealResult.insulinCalculated)U
+            """
+        )
+    }
+
+    @Test("Calculate insulin with super bolus") func testSuperBolusCalculation() async throws {
+        // STEP 1: Setup test scenario
+        // We need to provide a CalculationInput struct
+        let carbs: Decimal = 80
+        let currentBG: Decimal = 180 // 80 points above target, should result in 2U correction
+        let deltaBG: Decimal = 5 // Rising trend, should add small correction
+        let target: Decimal = 100
+        let isf: Decimal = 40
+        let carbRatio: Decimal = 10 // Should result in 8U for carbs
+        let iob: Decimal = 1.0 // Should subtract from final result
+        let cob: Int16 = 20
+        let useFattyMealCorrectionFactor: Bool = false
+        let useSuperBolus: Bool = true // Super bolus enabled
+        let fattyMealFactor: Decimal = 0.8
+        let sweetMealFactor: Decimal = 2
+        let basal: Decimal = 1.5 // Will be added to insulin calculation when super bolus is enabled
+        let fraction: Decimal = 0.8
+        let maxBolus: Decimal = 10
+
+        // STEP 2: Create calculation input with super bolus enabled
+        let input = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: useSuperBolus,
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+
+        // STEP 3: Calculate insulin with super bolus enabled
+        let superBolusResult = await calculator.calculateInsulin(input: input)
+
+        // STEP 4: Calculate insulin with super bolus disabled for comparison
+        let standardInput = CalculationInput(
+            carbs: carbs,
+            currentBG: currentBG,
+            deltaBG: deltaBG,
+            target: target,
+            isf: isf,
+            carbRatio: carbRatio,
+            iob: iob,
+            cob: cob,
+            useFattyMealCorrectionFactor: useFattyMealCorrectionFactor,
+            fattyMealFactor: fattyMealFactor,
+            useSuperBolus: false, // Disabled for comparison
+            sweetMealFactor: sweetMealFactor,
+            basal: basal,
+            fraction: fraction,
+            maxBolus: maxBolus
+        )
+        let standardResult = await calculator.calculateInsulin(input: standardInput)
+
+        // STEP 5: Verify results
+        // Super bolus should add basal rate * sweetMealFactor to the insulin calculation
+        let expectedSuperBolusInsulin = basal * sweetMealFactor
+        #expect(
+            superBolusResult.superBolusInsulin == expectedSuperBolusInsulin,
+            """
+            Super bolus insulin incorrect
+            Expected: \(expectedSuperBolusInsulin)U (basal \(basal)U × sweetMealFactor \(sweetMealFactor))
+            Actual: \(superBolusResult.superBolusInsulin)U
+            """
+        )
+
+        #expect(
+            superBolusResult.insulinCalculated > standardResult.insulinCalculated,
+            """
+            Super bolus calculation incorrect
+            Expected super bolus calculation to be higher than standard
+            Super bolus: \(superBolusResult.insulinCalculated)U
+            Standard: \(standardResult.insulinCalculated)U
+            Difference: \(superBolusResult.insulinCalculated - standardResult.insulinCalculated)U
+            """
+        )
+
+        // The difference should be the difference of super bolus (= standard dose + the basal rate * sweetMealFactor) limited by max bolus, and the standard dose.
+        let actualDifference = (superBolusResult.insulinCalculated - standardResult.insulinCalculated)
+        let expectedDifference = min(superBolusResult.insulinCalculated, maxBolus) - standardResult.insulinCalculated
+        #expect(
+            actualDifference == expectedDifference,
+            """
+            Super bolus difference incorrect
+            Expected difference: min(\(expectedSuperBolusInsulin), \(maxBolus)) U (basal \(basal)U × sweetMealFactor \(sweetMealFactor) + standard dose \(standardResult
+                .insulinCalculated)) - standard dose \(standardResult.insulinCalculated)
+            Actual difference: \(actualDifference)U
+            Standard result: \(standardResult)
+            SuperBolus result: \(superBolusResult)
+            """
+        )
+    }
+
+    @Test("Calculate insulin with zero carbs") func testZeroCarbsCalculation() async throws {
+        // Given
+        let carbs: Decimal = 0
+
+        // When
+        let result = await calculator.handleBolusCalculation(
+            carbs: carbs,
+            useFattyMealCorrection: false,
+            useSuperBolus: false
+        )
+
+        // Then
+        #expect(result.wholeCobInsulin == 0, "Zero carbs should require no insulin for carbs")
+    }
+
+    @Test("Verify settings retrieval") func testGetSettings() async throws {
+        // Given - Save original settings to restore later
+        let originalSettings = settingsManager.settings
+
+        // Setup test settings
+        let expectedUnits = GlucoseUnits.mgdL
+        let expectedFraction: Decimal = 0.7
+        let expectedFattyMealFactor: Decimal = 0.8
+        let expectedSweetMealFactor: Decimal = 2
+        let expectedMaxCarbs: Decimal = 150
+
+        // Update settings through settings manager
+        settingsManager.settings.units = expectedUnits
+        settingsManager.settings.overrideFactor = expectedFraction
+        settingsManager.settings.fattyMealFactor = expectedFattyMealFactor
+        settingsManager.settings.sweetMealFactor = expectedSweetMealFactor
+        settingsManager.settings.maxCarbs = expectedMaxCarbs
+
+        // Save settings to storage
+        fileStorage.save(settingsManager.settings, as: OpenAPS.Settings.settings)
+
+        // When
+        let (units, fraction, fattyMealFactor, sweetMealFactor, maxCarbs) = await getSettings()
+
+        // Then
+        #expect(units == expectedUnits, "Units should match settings")
+        #expect(fraction == expectedFraction, "Override factor should match settings")
+        #expect(fattyMealFactor == expectedFattyMealFactor, "Fatty meal factor should match settings")
+        #expect(sweetMealFactor == expectedSweetMealFactor, "Sweet meal factor should match settings")
+        #expect(maxCarbs == expectedMaxCarbs, "Max carbs should match settings")
+
+        // Cleanup - Restore original settings
+        settingsManager.settings = originalSettings
+        fileStorage.save(originalSettings, as: OpenAPS.Settings.settings)
+    }
+
+    @Test("Verify getCurrentSettingValue returns correct values based on time") func testGetCurrentSettingValue() async throws {
+        // STEP 1: Backup current settings
+        let originalBasalProfile = await fileStorage.retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
+        let originalCarbRatios = await fileStorage.retrieveAsync(OpenAPS.Settings.carbRatios, as: CarbRatios.self)
+        let originalBGTargets = await fileStorage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+        let originalISFValues = await fileStorage.retrieveAsync(
+            OpenAPS.Settings.insulinSensitivities,
+            as: InsulinSensitivities.self
+        )
+
+        // STEP 2: Setup test data with known values
+        // Note: Entries must be sorted by time for the algorithm to work correctly
+        let basalProfile = [
+            BasalProfileEntry(start: "00:00", minutes: 0, rate: 1.0), // 12:00 AM - 6:00 AM: 1.0
+            BasalProfileEntry(start: "06:00", minutes: 360, rate: 1.2), // 6:00 AM - 12:00 PM: 1.2
+            BasalProfileEntry(start: "12:00", minutes: 720, rate: 1.1), // 12:00 PM - 6:00 PM: 1.1
+            BasalProfileEntry(start: "18:00", minutes: 1080, rate: 0.9) // 6:00 PM - 12:00 AM: 0.9
+        ]
+
+        let carbRatios = CarbRatios(
+            units: .grams,
+            schedule: [
+                CarbRatioEntry(start: "00:00", offset: 0, ratio: 10), // 12:00 AM - 12:00 PM: 10
+                CarbRatioEntry(start: "12:00", offset: 720, ratio: 12) // 12:00 PM - 12:00 AM: 12
+            ]
+        )
+
+        let bgTargets = BGTargets(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            targets: [
+                BGTargetEntry(low: 100, high: 120, start: "00:00", offset: 0), // 12:00 AM - 8:00 AM: 100
+                BGTargetEntry(low: 90, high: 110, start: "08:00", offset: 480) // 8:00 AM - 12:00 AM: 90
+            ]
+        )
+
+        let isfValues = InsulinSensitivities(
+            units: .mgdL,
+            userPreferredUnits: .mgdL,
+            sensitivities: [
+                InsulinSensitivityEntry(sensitivity: 40, offset: 0, start: "00:00"), // 12:00 AM - 2:00 PM: 40
+                InsulinSensitivityEntry(sensitivity: 45, offset: 840, start: "14:00") // 2:00 PM - 12:00 AM: 45
+            ]
+        )
+
+        // STEP 3: Store test data
+        fileStorage.save(basalProfile, as: OpenAPS.Settings.basalProfile)
+        fileStorage.save(carbRatios, as: OpenAPS.Settings.carbRatios)
+        fileStorage.save(bgTargets, as: OpenAPS.Settings.bgTargets)
+        fileStorage.save(isfValues, as: OpenAPS.Settings.insulinSensitivities)
+
+        // STEP 4: Define test cases with specific times and expected values
+        // Format: (hour, minute, [setting type: expected value])
+        let testTimes: [(hour: Int, minute: Int, expected: [SettingType: Decimal])] = [
+            // Test midnight values (00:00)
+            (
+                hour: 0, minute: 0,
+                expected: [
+                    .basal: 1.0, // First basal rate
+                    .carbRatio: 10, // First carb ratio
+                    .bgTarget: 100, // First target
+                    .isf: 40 // First ISF
+                ]
+            ),
+            // Test mid-morning values (7:00)
+            (
+                hour: 7, minute: 0,
+                expected: [
+                    .basal: 1.2, // Second basal rate (after 6:00)
+                    .carbRatio: 10, // Still first carb ratio
+                    .bgTarget: 100, // Still first target
+                    .isf: 40 // Still first ISF
+                ]
+            ),
+            // Test afternoon values (15:00)
+            (
+                hour: 15, minute: 0,
+                expected: [
+                    .basal: 1.1, // Third basal rate (after 12:00)
+                    .carbRatio: 12, // Second carb ratio (after 12:00)
+                    .bgTarget: 90, // Second target
+                    .isf: 45 // Second ISF (after 14:00)
+                ]
+            )
+        ]
+
+        // STEP 5: Test each time point
+        for testTime in testTimes {
+            // Create a date object for the test time
+            let calendar = Calendar.current
+            var components = calendar.dateComponents([.year, .month, .day], from: Date())
+            components.hour = testTime.hour
+            components.minute = testTime.minute
+            components.second = 0
+            guard let testDate = calendar.date(from: components) else {
+                throw TestError("Failed to create test date")
+            }
+
+            // Test each setting type at this time
+            for (type, expectedValue) in testTime.expected {
+                // Get the actual value for this setting at the test time
+                let value = await getCurrentSettingValue(for: type, at: testDate)
+
+                // Compare with expected value
+                #expect(
+                    value == expectedValue,
+                    """
+                    Failed at \(testTime.hour):\(String(format: "%02d", testTime.minute))
+                    Setting: \(type)
+                    Expected: \(expectedValue)
+                    Actual: \(value)
+                    """
+                )
+            }
+        }
+
+        // STEP 6: Cleanup - Restore original settings
+        if let originalBasalProfile = originalBasalProfile {
+            fileStorage.save(originalBasalProfile, as: OpenAPS.Settings.basalProfile)
+        }
+        if let originalCarbRatios = originalCarbRatios {
+            fileStorage.save(originalCarbRatios, as: OpenAPS.Settings.carbRatios)
+        }
+        if let originalBGTargets = originalBGTargets {
+            fileStorage.save(originalBGTargets, as: OpenAPS.Settings.bgTargets)
+        }
+        if let originalISFValues = originalISFValues {
+            fileStorage.save(originalISFValues, as: OpenAPS.Settings.insulinSensitivities)
+        }
+    }
+}
+
+// Copied over from BolusCalculationManager as they are not included in the protocol definition (and I don´t want them to be included)
+
+extension BolusCalculatorTests {
+    private enum SettingType {
+        case basal
+        case carbRatio
+        case bgTarget
+        case isf
+    }
+
+    /// Retrieves current settings from the SettingsManager
+    /// - Returns: Tuple containing units, fraction, fattyMealFactor, sweetMealFactor, and maxCarbs settings
+    private func getSettings() async -> (
+        units: GlucoseUnits,
+        fraction: Decimal,
+        fattyMealFactor: Decimal,
+        sweetMealFactor: Decimal,
+        maxCarbs: Decimal
+    ) {
+        return (
+            units: settingsManager.settings.units,
+            fraction: settingsManager.settings.overrideFactor,
+            fattyMealFactor: settingsManager.settings.fattyMealFactor,
+            sweetMealFactor: settingsManager.settings.sweetMealFactor,
+            maxCarbs: settingsManager.settings.maxCarbs
+        )
+    }
+
+    /// Gets the current setting value for a specific setting type based on the time of day
+    /// - Parameter type: The type of setting to retrieve (basal, carbRatio, bgTarget, or isf)
+    /// - Returns: The current decimal value for the specified setting type
+    private func getCurrentSettingValue(for type: SettingType, at date: Date) async -> Decimal {
+        let calendar = Calendar.current
+        let midnight = calendar.startOfDay(for: date)
+        let minutesSinceMidnight = calendar.dateComponents([.minute], from: midnight, to: date).minute ?? 0
+
+        switch type {
+        case .basal:
+            let profile = await getBasalProfile()
+            return profile.last { $0.minutes <= minutesSinceMidnight }?.rate ?? 0
+
+        case .carbRatio:
+            let ratios = await getCarbRatios()
+            return ratios.schedule.last { $0.offset <= minutesSinceMidnight }?.ratio ?? 0
+
+        case .bgTarget:
+            let targets = await getBGTargets()
+            return targets.targets.last { $0.offset <= minutesSinceMidnight }?.low ?? 0
+
+        case .isf:
+            let sensitivities = await getISFValues()
+            return sensitivities.sensitivities.last { $0.offset <= minutesSinceMidnight }?.sensitivity ?? 0
+        }
+    }
+
+    /// Retrieves the pump settings from storage
+    /// - Returns: PumpSettings object containing pump configuration
+    private func getPumpSettings() async -> PumpSettings {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.settings, as: PumpSettings.self)
+            ?? PumpSettings(from: OpenAPS.defaults(for: OpenAPS.Settings.settings))
+            ?? PumpSettings(insulinActionCurve: 10, maxBolus: 10, maxBasal: 2)
+    }
+
+    /// Retrieves the basal profile from storage
+    /// - Returns: Array of BasalProfileEntry objects
+    private func getBasalProfile() async -> [BasalProfileEntry] {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self)
+            ?? [BasalProfileEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.basalProfile))
+            ?? []
+    }
+
+    /// Retrieves carb ratios from storage
+    /// - Returns: CarbRatios object containing carb ratio schedule
+    private func getCarbRatios() async -> CarbRatios {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.carbRatios, as: CarbRatios.self)
+            ?? CarbRatios(from: OpenAPS.defaults(for: OpenAPS.Settings.carbRatios))
+            ?? CarbRatios(units: .grams, schedule: [])
+    }
+
+    /// Retrieves blood glucose targets from storage
+    /// - Returns: BGTargets object containing target schedule
+    private func getBGTargets() async -> BGTargets {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+            ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
+            ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
+    }
+
+    /// Retrieves insulin sensitivity factors from storage
+    /// - Returns: InsulinSensitivities object containing sensitivity schedule
+    private func getISFValues() async -> InsulinSensitivities {
+        await fileStorage.retrieveAsync(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self)
+            ?? InsulinSensitivities(from: OpenAPS.defaults(for: OpenAPS.Settings.insulinSensitivities))
+            ?? InsulinSensitivities(
+                units: .mgdL,
+                userPreferredUnits: .mgdL,
+                sensitivities: []
+            )
+    }
+}

+ 46 - 29
TrioTests/CalibrationsTests.swift

@@ -1,59 +1,76 @@
+import Foundation
 import Swinject
+import Testing
+
 @testable import Trio
-import XCTest
 
-class CalibrationsTests: XCTestCase, Injectable {
+@Suite("Calibration Service Tests", .serialized) struct CalibrationTests: Injectable {
     let fileStorage = BaseFileStorage()
     @Injected() var calibrationService: CalibrationService!
     let resolver = TrioApp().resolver
 
-    override func setUp() {
+    init() {
         injectServices(resolver)
     }
 
-    func testCreateSimpleCalibration() {
-        // restore state so each test is independent
+    @Test("Can create simple calibration") func testCreateSimpleCalibration() {
+        // Given
         calibrationService.removeAllCalibrations()
-
         let calibration = Calibration(x: 100.0, y: 102.0)
-        calibrationService.addCalibration(calibration)
-
-        XCTAssertTrue(calibrationService.calibrations.isNotEmpty)
-
-        XCTAssertTrue(calibrationService.slope == 1)
 
-        XCTAssertTrue(calibrationService.intercept == 2)
+        // When
+        calibrationService.addCalibration(calibration)
 
-        XCTAssertTrue(calibrationService.calibrate(value: 104) == 106)
+        // Then
+        #expect(calibrationService.calibrations.isNotEmpty)
+        #expect(calibrationService.slope == 1)
+        #expect(calibrationService.intercept == 2)
+        #expect(calibrationService.calibrate(value: 104) == 106)
     }
 
-    func testCreateMultipleCalibration() {
-        // restore state so each test is independent
+    @Test("Can handle multiple calibrations") func testCreateMultipleCalibration() {
+        // Given
         calibrationService.removeAllCalibrations()
-
         let calibration = Calibration(x: 100.0, y: 120)
-        calibrationService.addCalibration(calibration)
-
         let calibration2 = Calibration(x: 120.0, y: 130.0)
+
+        // When
+        calibrationService.addCalibration(calibration)
         calibrationService.addCalibration(calibration2)
 
-        XCTAssertEqual(calibrationService.slope, 0.8, accuracy: 0.0001)
-        XCTAssertEqual(calibrationService.intercept, 37, accuracy: 0.0001)
-        XCTAssertEqual(calibrationService.calibrate(value: 80), 101, accuracy: 0.0001)
+        // Then
+        #expect(abs(calibrationService.slope - 0.8) < 0.0001)
+        #expect(abs(calibrationService.intercept - 37) < 0.0001)
+        #expect(abs(calibrationService.calibrate(value: 80) - 101) < 0.0001)
 
+        // When removing last
         calibrationService.removeLast()
+        #expect(calibrationService.calibrations.count == 1)
 
-        XCTAssertTrue(calibrationService.calibrations.count == 1)
-
+        // When removing all
         calibrationService.removeAllCalibrations()
-        XCTAssertTrue(calibrationService.calibrations.isEmpty)
+        #expect(calibrationService.calibrations.isEmpty)
     }
 
-    override func setUpWithError() throws {
-        // Put setup code here. This method is called before the invocation of each test method in the class.
-    }
+    @Test("Handles calibration bounds correctly") func testCalibrationBounds() {
+        // Given
+        calibrationService.removeAllCalibrations()
+
+        // When no calibrations exist
+        #expect(calibrationService.slope == 1, "Default slope should be 1")
+        #expect(calibrationService.intercept == 0, "Default intercept should be 0")
+
+        // When adding extreme values
+        let extremeCalibration1 = Calibration(x: 0.0, y: 1000.0) // Should be clamped
+        let extremeCalibration2 = Calibration(x: 1000.0, y: 0.0) // Should be clamped
+
+        calibrationService.addCalibration(extremeCalibration1)
+        calibrationService.addCalibration(extremeCalibration2)
 
-    override func tearDownWithError() throws {
-        // Put teardown code here. This method is called after the invocation of each test method in the class.
+        // Then check bounds
+        #expect(calibrationService.slope >= 0.8, "Slope should not be less than minimum")
+        #expect(calibrationService.slope <= 1.25, "Slope should not be more than maximum")
+        #expect(calibrationService.intercept >= -100, "Intercept should not be less than minimum")
+        #expect(calibrationService.intercept <= 100, "Intercept should not be more than maximum")
     }
 }

+ 208 - 0
TrioTests/CoreDataTests/CarbsStorageTests.swift

@@ -0,0 +1,208 @@
+import CoreData
+import Foundation
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("CarbsStorage Tests", .serialized) struct CarbsStorageTests: Injectable {
+    @Injected() var storage: CarbsStorage!
+    let resolver: Resolver
+    let coreDataStack = CoreDataStack.createForTests()
+    let testContext: NSManagedObjectContext
+
+    init() {
+        // Create test context
+        testContext = coreDataStack.newTaskContext()
+
+        // Create assembler with test assembly
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext)
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+    }
+
+    @Test("Storage is correctly initialized") func testStorageInitialization() {
+        #expect(storage != nil, "CarbsStorage should be injected")
+        #expect(storage is BaseCarbsStorage, "Storage should be of type BaseCarbsStorage")
+        #expect(storage.updatePublisher != nil, "Update publisher should be available")
+    }
+
+    @Test("Store and retrieve carbs entries") func testStoreAndRetrieveCarbs() async throws {
+        // Given
+        let testEntries = [
+            CarbsEntry(
+                id: UUID().uuidString,
+                createdAt: Date(),
+                actualDate: Date(),
+                carbs: 20,
+                fat: 0,
+                protein: 0,
+                note: "Test meal",
+                enteredBy: "Test",
+                isFPU: false,
+                fpuID: nil
+            )
+        ]
+
+        // When
+        try await storage.storeCarbs(testEntries, areFetchedFromRemote: false)
+        let recentEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "TRUEPREDICATE"),
+            key: "date",
+            ascending: false
+        )
+
+        guard let recentEntries = recentEntries as? [CarbEntryStored] else {
+            throw TestError("Failed to get recent entries")
+        }
+
+        // Then
+        #expect(!recentEntries.isEmpty, "Should have stored entries")
+        #expect(recentEntries.count == 1, "Should have exactly one entry")
+        #expect(recentEntries[0].carbs == 20, "Carbs value should match")
+        #expect(recentEntries[0].fat == 0, "Fat value should match")
+        #expect(recentEntries[0].protein == 0, "Protein value should match")
+        #expect(recentEntries[0].note == "Test meal", "Note should match")
+    }
+
+    @Test("Delete carbs entry") func testDeleteCarbsEntry() async throws {
+        // Given
+        let testEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: Date(),
+            actualDate: Date(),
+            carbs: 30,
+            fat: nil,
+            protein: nil,
+            note: "Delete test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: nil
+        )
+
+        // When
+        try await storage.storeCarbs([testEntry], areFetchedFromRemote: false)
+
+        // Get the stored entry's ObjectID
+        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "carbs == 30"),
+            key: "date",
+            ascending: false
+        ) as? [CarbEntryStored]
+
+        guard let objectID = storedEntries?.first?.objectID else {
+            throw TestError("Failed to get stored entry's ObjectID")
+        }
+
+        // Delete the entry
+        await storage.deleteCarbsEntryStored(objectID)
+
+        // Then - verify deletion
+        let remainingEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "carbs == 30"),
+            key: "date",
+            ascending: false
+        ) as? [CarbEntryStored]
+
+        #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
+    }
+
+    @Test("Get carbs not yet uploaded to Nightscout") func testGetCarbsNotYetUploadedToNightscout() async throws {
+        // Given
+        let testEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: Date(),
+            actualDate: Date(),
+            carbs: 40,
+            fat: nil,
+            protein: nil,
+            note: "NS test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: nil
+        )
+
+        // When
+        try await storage.storeCarbs([testEntry], areFetchedFromRemote: false)
+        let notUploadedEntries = try await storage.getCarbsNotYetUploadedToNightscout()
+
+        // Then
+        #expect(!notUploadedEntries.isEmpty, "Should have entries not uploaded to NS")
+        #expect(notUploadedEntries[0].carbs == 40, "Carbs value should match")
+    }
+
+    @Test("Get FPUs not yet uploaded to Nightscout") func testGetFPUsNotYetUploadedToNightscout() async throws {
+        // Given
+        let fpuID = UUID().uuidString
+        let testEntry = CarbsEntry(
+            id: UUID().uuidString,
+            createdAt: Date(),
+            actualDate: Date(),
+            carbs: 30,
+            fat: 20,
+            protein: 10,
+            note: "FPU test",
+            enteredBy: "Test",
+            isFPU: false,
+            fpuID: fpuID
+        )
+
+        // When
+        try await storage.storeCarbs([testEntry], areFetchedFromRemote: false)
+
+        // First verify all stored entries
+        let allStoredEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "fpuID == %@", fpuID),
+            key: "date",
+            ascending: true
+        ) as? [CarbEntryStored]
+
+        // Then verify the stored entries
+        #expect(allStoredEntries?.isEmpty == false, "Should have stored entries")
+        #expect(allStoredEntries?.count ?? 0 > 1, "Should have multiple entries due to FPU splitting")
+
+        // Original carb-non-fpu entry should be stored with original fat and protein values and isFPU set to false
+        let carbNonFpuEntry = allStoredEntries?.first(where: { $0.isFPU == false })
+        #expect(carbNonFpuEntry != nil, "Should have one carb non-fpu entry")
+        #expect(carbNonFpuEntry?.carbs == 30, "Original carbs should match")
+        #expect(carbNonFpuEntry?.protein == 10, "Original carbs should match")
+        #expect(carbNonFpuEntry?.fat == 20, "Original carbs should match")
+
+        // Additional carb-fpu entries should be created for fat/protein with isFPU set to true and the carbs set to the amount of each carbEquivalent
+        let carbFpuEntry = allStoredEntries?.filter { $0.isFPU == true }
+        #expect(carbFpuEntry?.isEmpty == false, "Should have additional carb-fpu entries")
+
+        // Now test the Nightscout upload function
+        let notUploadedFPUs = try await storage.getFPUsNotYetUploadedToNightscout()
+
+        // Then verify Nightscout entries
+        #expect(!notUploadedFPUs.isEmpty, "Should have FPUs not uploaded to NS")
+        let fpu = notUploadedFPUs[0]
+        #expect(fpu.carbs ?? 0 < 30, "Original carbs value should match")
+        #expect(fpu.protein == 0, "Protein value should match")
+        #expect(fpu.fat == 0, "Fat value should match")
+
+        // Verify all entries share the same fpuID
+        #expect(
+            allStoredEntries?.allSatisfy { $0.fpuID?.uuidString == fpuID } == true,
+            "All entries should share the same fpuID"
+        )
+    }
+}

+ 230 - 0
TrioTests/CoreDataTests/DeterminationStorageTests.swift

@@ -0,0 +1,230 @@
+import CoreData
+import Foundation
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("Determination Storage Tests", .serialized) struct DeterminationStorageTests: Injectable {
+    @Injected() var storage: DeterminationStorage!
+    let resolver: Resolver
+    let coreDataStack = CoreDataStack.createForTests()
+    let testContext: NSManagedObjectContext
+
+    init() {
+        // Create test context
+        // As we are only using this single test context to initialize our in-memory DeterminationStorage we need to perform the Unit Tests serialized
+        testContext = coreDataStack.newTaskContext()
+
+        // Create assembler with test assembly
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext) // Add our test assembly last to override Storage
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+    }
+
+    @Test("Storage is correctly initialized") func testStorageInitialization() {
+        // Verify storage exists
+        #expect(storage != nil, "DeterminationStorage should be injected")
+
+        // Verify it's the correct type
+        #expect(storage is BaseDeterminationStorage, "Storage should be of type BaseDeterminationStorage")
+    }
+
+    @Test("Test fetchLastDeterminationObjectID with different predicates") func testFetchLastDeterminationWithPredicates() async throws {
+        // Given
+        let date = Date()
+        let id = UUID()
+
+        // Create a mock determination
+        await testContext.perform {
+            let determination = OrefDetermination(context: testContext)
+            determination.id = id
+            determination.deliverAt = date
+            determination.timestamp = date
+            determination.enacted = true
+            determination.isUploadedToNS = true
+            try? testContext.save()
+        }
+
+        // Tests with predicates that we use the most for this function
+        // 1. Test within 30 minutes
+        let results = try await storage
+            .fetchLastDeterminationObjectID(predicate: NSPredicate.predicateFor30MinAgoForDetermination)
+        #expect(results.count == 1, "Should find 1 determination within 30 minutes")
+        // Get NSManagedObjectID from exactDateResults
+        try await testContext.perform {
+            do {
+                guard let results = results.first,
+                      let object = try testContext.existingObject(with: results) as? OrefDetermination
+                else {
+                    throw TestError("Failed to fetch determination")
+                }
+                #expect(object.timestamp == date, "Determination within 30 minutes should have the same timestamp as date")
+                #expect(object.deliverAt == date, "Determination within 30 minutes should have the same deliverAt as date")
+                #expect(object.enacted == true, "Determination within 30 minutes should be enacted")
+                #expect(object.isUploadedToNS == true, "Determination within 30 minutes should be uploaded to NS")
+                #expect(object.id == id, "Determination within 30 minutes should have the same id")
+            } catch {
+                throw TestError("Failed to fetch determination")
+            }
+        }
+
+        // 2. Test enacted determinations
+        let enactedPredicate = NSPredicate.enactedDetermination
+        let enactedResults = try await storage.fetchLastDeterminationObjectID(predicate: enactedPredicate)
+        #expect(enactedResults.count == 1, "Should find 1 enacted determination")
+        // Get NSManagedObjectID from enactedResults
+        try await testContext.perform {
+            do {
+                guard let results = enactedResults.first,
+                      let object = try testContext.existingObject(with: results) as? OrefDetermination
+                else {
+                    throw TestError("Failed to fetch determination")
+                }
+                #expect(object.enacted == true, "Enacted determination should be enacted")
+                #expect(object.isUploadedToNS == true, "Enacted determination should be uploaded to NS")
+                #expect(object.id == id, "Enacted determination should have the same id")
+                #expect(object.timestamp == date, "Enacted determination should have the same timestamp")
+                #expect(object.deliverAt == date, "Enacted determination should have the same deliverAt")
+
+                // Delete the determination
+                testContext.delete(object)
+                try testContext.save()
+            } catch {
+                throw TestError("Failed to fetch determination")
+            }
+        }
+    }
+
+    @Test("Test complete forecast hierarchy prefetching") func testForecastHierarchyPrefetching() async throws {
+        // Given
+        let date = Date()
+        let forecastTypes = ["iob", "cob", "zt", "uam"]
+        let expectedValuesPerForecast = 5
+
+        // STEP 1: Create test data
+        let id = try await createTestData(
+            date: date,
+            forecastTypes: forecastTypes,
+            expectedValuesPerForecast: expectedValuesPerForecast
+        )
+
+        // STEP 2: Test hierarchy fetching
+        let hierarchy = try await storage.fetchForecastHierarchy(
+            for: id,
+            in: testContext
+        )
+
+        // Test hierarchy structure
+        #expect(hierarchy.count == forecastTypes.count, "Should have correct number of forecasts")
+
+        // STEP 3: Test individual forecasts
+        for data in hierarchy {
+            let (_, forecast, values) = await storage.fetchForecastObjects(
+                for: data,
+                in: testContext
+            )
+
+            // Test basic structure
+            #expect(forecast != nil, "Forecast should exist")
+            #expect(values.count == expectedValuesPerForecast, "Should have correct number of values")
+
+            // Test forecast type and values
+            if let forecast = forecast {
+                #expect(forecastTypes.contains(forecast.type ?? ""), "Should have valid forecast type")
+
+                // Test value patterns
+                let sortedValues = values.sorted { $0.index < $1.index }
+                switch forecast.type {
+                case "iob":
+                    #expect(sortedValues.first?.value == 100, "IOB should start at 100")
+                    #expect(sortedValues.last?.value == 140, "IOB should end at 140")
+                case "cob":
+                    #expect(sortedValues.first?.value == 50, "COB should start at 50")
+                    #expect(sortedValues.last?.value == 70, "COB should end at 70")
+                case "zt":
+                    #expect(sortedValues.first?.value == 80, "ZT should start at 80")
+                    #expect(sortedValues.last?.value == 112, "ZT should end at 112")
+                case "uam":
+                    #expect(sortedValues.first?.value == 120, "UAM should start at 120")
+                    #expect(sortedValues.last?.value == 60, "UAM should end at 60")
+                default:
+                    break
+                }
+            }
+        }
+
+        // STEP 4: Test relationship integrity
+        try await testContext.perform {
+            do {
+                let determination = try testContext.existingObject(with: id) as? OrefDetermination
+                let forecasts = Array(determination?.forecasts ?? [])
+
+                #expect(forecasts.count == forecastTypes.count, "Determination should have all forecasts")
+                #expect(
+                    forecasts.allSatisfy { Array($0.forecastValues ?? []).count == expectedValuesPerForecast },
+                    "Each forecast should have correct number of values"
+                )
+            } catch {
+                throw TestError("Failed to verify relationships: \(error)")
+            }
+        }
+    }
+
+    private func createTestData(
+        date: Date,
+        forecastTypes: [String],
+        expectedValuesPerForecast: Int
+    ) async throws -> NSManagedObjectID {
+        try await testContext.perform {
+            let determination = OrefDetermination(context: testContext)
+            determination.id = UUID()
+            determination.deliverAt = date
+            determination.timestamp = date
+            determination.enacted = true
+
+            // Create all forecast types with values
+            for type in forecastTypes {
+                let forecast = Forecast(context: testContext)
+                forecast.id = UUID()
+                forecast.date = date
+                forecast.type = type
+                forecast.orefDetermination = determination
+
+                // Add test values with different patterns per type
+                for i in 0 ..< expectedValuesPerForecast {
+                    let value = ForecastValue(context: testContext)
+                    value.index = Int32(i)
+
+                    // Different value patterns for each type
+                    switch type {
+                    case "iob": value.value = Int32(100 + i * 10) // 100, 110, 120...
+                    case "cob": value.value = Int32(50 + i * 5) // 50, 55, 60...
+                    case "zt": value.value = Int32(80 + i * 8) // 80, 88, 96...
+                    case "uam": value.value = Int32(120 - i * 15) // 120, 105, 90...
+                    default: value.value = 0
+                    }
+
+                    value.forecast = forecast
+                }
+            }
+
+            do {
+                try testContext.save()
+
+                return determination.objectID
+            } catch {
+                throw TestError("Failed to create test data: \(error)")
+            }
+        }
+    }
+}

+ 187 - 0
TrioTests/CoreDataTests/GlucoseStorageTests.swift

@@ -0,0 +1,187 @@
+import CoreData
+import Foundation
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("GlucoseStorage Tests", .serialized) struct GlucoseStorageTests: Injectable {
+    @Injected() var storage: GlucoseStorage!
+    let resolver: Resolver
+    let coreDataStack = CoreDataStack.createForTests()
+    let testContext: NSManagedObjectContext
+
+    init() {
+        // Create test context
+        // As we are only using this single test context to initialize our in-memory DeterminationStorage we need to perform the Unit Tests serialized
+        testContext = coreDataStack.newTaskContext()
+
+        // Create assembler with test assembly
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext) // Add our test assembly last to override Storage
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+    }
+
+    @Test("Storage is correctly initialized") func testStorageInitialization() {
+        // Verify storage exists
+        #expect(storage != nil, "GlucoseStorage should be injected")
+
+        // Verify it's the correct type
+        #expect(storage is BaseGlucoseStorage, "Storage should be of type BaseGlucoseStorage")
+    }
+
+    @Test("Store and retrieve glucose entries") func testStoreAndRetrieveGlucose() async throws {
+        // Given
+        let testGlucose = [
+            BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 126)
+        ]
+
+        // When
+        try await storage.storeGlucose(testGlucose)
+
+        // Then verify stored entries
+        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 126"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored]
+
+        #expect(storedEntries?.isEmpty == false, "Should have stored entries")
+        #expect(storedEntries?.count == 1, "Should have exactly one entry")
+        #expect(storedEntries?[0].glucose == 126, "Glucose value should match")
+        #expect(storedEntries?[0].direction == "Flat", "Direction should match")
+    }
+
+    @Test("Delete glucose entry") func testDeleteGlucose() async throws {
+        // Given
+        let testGlucose = [
+            BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 140)
+        ]
+        try await storage.storeGlucose(testGlucose)
+
+        // Get the stored entry's ObjectID
+        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 140"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored]
+
+        guard let objectID = storedEntries?.first?.objectID else {
+            throw TestError("Failed to get stored entry's ObjectID")
+        }
+
+        #expect(storedEntries.isNotNilNotEmpty == true, "Should have exactly one (test) entry")
+
+        // When
+        await storage.deleteGlucose(objectID)
+
+        // Then verify deletion
+        let remainingEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 140"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored]
+
+        #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
+    }
+
+    @Test("Get glucose not yet uploaded to Nightscout") func testGetGlucoseNotYetUploadedToNightscout() async throws {
+        // Given
+        let testGlucose = [
+            BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 160)
+        ]
+        try await storage.storeGlucose(testGlucose)
+
+        // When
+        let notUploadedEntries = try await storage.getGlucoseNotYetUploadedToNightscout()
+
+        // Then
+        #expect(!notUploadedEntries.isEmpty, "Should have entries not uploaded to NS")
+        #expect(notUploadedEntries[0].glucose == 160, "Glucose value should match")
+    }
+
+    @Test("Get manual glucose not yet uploaded to Nightscout") func testGetManualGlucoseNotYetUploadedToNightscout() async throws {
+        // Given
+        storage.addManualGlucose(glucose: 180)
+
+        // When
+        let notUploadedEntries = try await storage.getManualGlucoseNotYetUploadedToNightscout()
+
+        // Then
+        #expect(!notUploadedEntries.isEmpty, "Should have manual entries not uploaded to NS")
+        let entry = notUploadedEntries[0]
+        #expect(entry.glucose == "180", "Glucose value should match")
+        #expect(entry.glucoseType == "Manual", "Type should be mbg for manual entries")
+        #expect(entry.eventType == .capillaryGlucose, "Type should be capillaryGlucose")
+    }
+
+    @Test("Test glucose alarms") func testGlucoseAlarms() async throws {
+        // Given
+        let lowGlucose = [
+            BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 55)
+        ]
+        let highGlucose = [
+            BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 271)
+        ]
+        let normalGlucose = [
+            BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 100)
+        ]
+
+        // When - Test low glucose
+        try await storage.storeGlucose(lowGlucose)
+        var storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 55"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored]
+
+        // Then
+        #expect(storedEntries?.first?.glucose == 55, "Low glucose value should match")
+        #expect(storage.alarm == .low, "Should trigger low glucose alarm") // default low limit is 72 mg/dL
+
+        // When - Test high glucose
+        try await storage.storeGlucose(highGlucose)
+        storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 271"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored]
+
+        // Then
+        #expect(storedEntries?.first?.glucose == 271, "High glucose value should match")
+        #expect(storage.alarm == .high, "Should trigger high glucose alarm") // default high limit is 270 mg/dL
+
+        // When - Test normal glucose
+        try await storage.storeGlucose(normalGlucose)
+        storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "glucose == 100"),
+            key: "date",
+            ascending: false
+        ) as? [GlucoseStored]
+
+        // Then
+        #expect(storedEntries?.first?.glucose == 100, "Normal glucose value should match")
+        #expect(storage.alarm == nil, "Should not trigger any alarm")
+    }
+}

+ 224 - 0
TrioTests/CoreDataTests/OverrideStorageTests.swift

@@ -0,0 +1,224 @@
+import CoreData
+import Foundation
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("Override Storage Tests", .serialized) struct OverrideStorageTests: Injectable {
+    @Injected() var storage: OverrideStorage!
+    let resolver: Resolver
+    let coreDataStack = CoreDataStack.createForTests()
+    let testContext: NSManagedObjectContext
+
+    init() {
+        // Create test context
+        // As we are only using this single test context to initialize our in-memory DeterminationStorage we need to perform the Unit Tests serialized
+        testContext = coreDataStack.newTaskContext()
+
+        // Create assembler with test assembly
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext) // Add our test assembly last to override Storage
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+    }
+
+    @Test("Storage is correctly initialized") func testStorageInitialization() {
+        // Verify storage exists
+        #expect(storage != nil, "OverrideStorage should be injected")
+
+        // Verify it's the correct type
+        #expect(storage is BaseOverrideStorage, "Storage should be of type BaseOverrideStorage")
+    }
+
+    @Test("Store and retrieve override") func testStoreAndRetrieveOverride() async throws {
+        // Given
+        let testOverride = Override(
+            name: "Test Override",
+            enabled: false,
+            date: Date(),
+            duration: 120,
+            indefinite: false,
+            percentage: 130,
+            smbIsOff: true,
+            isPreset: false,
+            id: UUID().uuidString,
+            overrideTarget: true,
+            target: 110,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: false,
+            start: 1,
+            end: 2,
+            smbMinutes: 100,
+            uamMinutes: 120
+        )
+
+        // When
+        try await storage.storeOverride(override: testOverride)
+
+        // Then verify stored entries
+        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OverrideStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "name == %@", "Test Override"),
+            key: "date",
+            ascending: false
+        ) as? [OverrideStored]
+
+        #expect(storedEntries?.isEmpty == false, "Should have stored entries")
+        #expect(storedEntries?.count == 1, "Should have exactly one entry")
+        let storedOverride = storedEntries?.first
+        #expect(storedOverride?.name == "Test Override", "Name should match")
+        #expect(storedOverride?.percentage == 130, "Percentage should match")
+        #expect(storedOverride?.target?.decimalValue == 110, "Target should match")
+        #expect(storedOverride?.isPreset == false, "isPreset should match")
+    }
+
+    @Test("Store and retrieve override preset") func testStoreAndRetrieveOverridePreset() async throws {
+        // Given
+        let testPreset = Override(
+            name: "Test Preset",
+            enabled: false,
+            date: Date(),
+            duration: 0,
+            indefinite: true,
+            percentage: 120,
+            smbIsOff: true,
+            isPreset: true,
+            id: UUID().uuidString,
+            overrideTarget: true,
+            target: 110,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: false,
+            start: 1,
+            end: 2,
+            smbMinutes: 100,
+            uamMinutes: 120
+        )
+
+        // When
+        try await storage.storeOverride(override: testPreset)
+        let presetIDs = try await storage.fetchForOverridePresets()
+
+        // Then
+        #expect(!presetIDs.isEmpty, "Should have stored preset")
+
+        let storedPresets = try await testContext.perform {
+            try presetIDs.map { try testContext.existingObject(with: $0) as! OverrideStored }
+        }
+
+        #expect(storedPresets.count >= 1, "Should have at least one preset")
+        let storedPreset = storedPresets.first { $0.name == "Test Preset" }
+        #expect(storedPreset != nil, "Should find the test preset")
+        #expect(storedPreset?.isPreset == true, "Should be marked as preset")
+        #expect(storedPreset?.indefinite == true, "Should be indefinite")
+        #expect(storedPreset?.percentage == 120, "Percentage should match")
+    }
+
+    @Test("Delete override preset") func testDeleteOverridePreset() async throws {
+        // Given
+        let testPreset = Override(
+            name: "Delete Test",
+            enabled: false,
+            date: Date(),
+            duration: 0,
+            indefinite: true,
+            percentage: 120,
+            smbIsOff: true,
+            isPreset: true,
+            id: UUID().uuidString,
+            overrideTarget: true,
+            target: 110,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: false,
+            start: 1,
+            end: 2,
+            smbMinutes: 100,
+            uamMinutes: 120
+        )
+
+        // Store the preset
+        try await storage.storeOverride(override: testPreset)
+
+        // Get the stored preset's ObjectID
+        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OverrideStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "name == %@", "Delete Test"),
+            key: "date",
+            ascending: false
+        ) as? [OverrideStored]
+
+        guard let objectID = storedEntries?.first?.objectID else {
+            throw TestError("Failed to get stored preset's ObjectID")
+        }
+
+        // When
+        await storage.deleteOverridePreset(objectID)
+
+        // Then verify deletion
+        let remainingEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: OverrideStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "name == %@", "Delete Test"),
+            key: "date",
+            ascending: false
+        ) as? [OverrideStored]
+
+        #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
+    }
+
+    @Test("Get overrides not yet uploaded to Nightscout") func testGetOverridesNotYetUploadedToNightscout() async throws {
+        // Given
+        let testOverride = Override(
+            name: "NS Test",
+            enabled: true, // getOverridesNotYetUploadedToNightscout() fetches only active overrides
+            date: Date(),
+            duration: 90,
+            indefinite: false,
+            percentage: 120,
+            smbIsOff: true,
+            isPreset: true,
+            id: UUID().uuidString,
+            overrideTarget: true,
+            target: 110,
+            advancedSettings: false,
+            isfAndCr: false,
+            isf: false,
+            cr: false,
+            smbIsScheduledOff: false,
+            start: 1,
+            end: 2,
+            smbMinutes: 100,
+            uamMinutes: 120
+        )
+
+        // When
+        try await storage.storeOverride(override: testOverride)
+
+        let notUploadedOverrides = try await storage.getOverridesNotYetUploadedToNightscout()
+
+        // Then
+        #expect(!notUploadedOverrides.isEmpty == true, "Should have overrides not uploaded to NS")
+        #expect(notUploadedOverrides[0].notes == "NS Test", "Override name should match")
+        #expect(notUploadedOverrides[0].duration == 90, "Duration should match")
+        #expect(notUploadedOverrides[0].eventType == .nsExercise, "Event type should be exercise")
+    }
+}

+ 288 - 0
TrioTests/CoreDataTests/PumpHistoryStorageTests.swift

@@ -0,0 +1,288 @@
+import CoreData
+import Foundation
+import Swinject
+import Testing
+
+@testable import LoopKit
+@testable import Trio
+
+@Suite("PumpHistoryStorage Tests", .serialized) struct PumpHistoryStorageTests: Injectable {
+    @Injected() var storage: PumpHistoryStorage!
+    let resolver: Resolver
+    let coreDataStack = CoreDataStack.createForTests()
+    let testContext: NSManagedObjectContext
+    typealias PumpEvent = PumpEventStored.EventType
+
+    init() {
+        // Create test context
+        testContext = coreDataStack.newTaskContext()
+
+        // Create assembler with test assembly
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext) // Add our test assembly last to override PumpHistoryStorage
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+    }
+
+    @Test("Storage is correctly initialized") func testStorageInitialization() {
+        // Verify storage exists
+        #expect(storage != nil, "PumpHistoryStorage should be injected")
+
+        // Verify it's the correct type
+        #expect(
+            storage is BasePumpHistoryStorage, "Storage should be of type BasePumpHistoryStorage"
+        )
+
+        // Verify we can access the update publisher
+        #expect(storage.updatePublisher != nil, "Update publisher should be available")
+    }
+
+    @Test("Test read and delete using generic CoreDataStack functions") func testFetchAndDeletePumpEvents() async throws {
+        // Given
+        let date = Date()
+
+        // Insert mock entry
+        let events: [LoopKit.NewPumpEvent] = [
+            LoopKit.NewPumpEvent(
+                date: date,
+                dose: LoopKit.DoseEntry(
+                    type: .bolus,
+                    startDate: date,
+                    value: 0.5,
+                    unit: .units,
+                    deliveredUnits: nil,
+                    description: nil,
+                    syncIdentifier: nil,
+                    scheduledBasalRate: nil,
+                    insulinType: .lyumjev,
+                    automatic: false,
+                    manuallyEntered: false,
+                    isMutable: false
+                ),
+                raw: Data(),
+                title: "Test Bolus for Fetch",
+                type: .bolus
+            )
+        ]
+
+        // Store test event
+        try await storage.storePumpEvents(events)
+
+        // When - Fetch events with our generic fetch function
+        let fetchedEvents = try await coreDataStack.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(
+                format: "type == %@ AND timestamp == %@",
+                PumpEvent.bolus.rawValue,
+                date as NSDate
+            ),
+            key: "timestamp",
+            ascending: false
+        )
+
+        guard let fetchedEvents = fetchedEvents as? [PumpEventStored] else { return }
+
+        // Then
+        #expect(fetchedEvents.count == 1, "Should have found exactly one event")
+        let fetchedEvent = fetchedEvents.first
+        #expect(fetchedEvent?.type == PumpEvent.bolus.rawValue, "Should be a bolus event")
+        #expect(fetchedEvent?.bolus?.amount as? Decimal == 0.5, "Bolus amount should be 0.5")
+        #expect(
+            abs(fetchedEvent?.timestamp?.timeIntervalSince(date) ?? 1) < 1,
+            "Timestamp should match"
+        )
+
+        // When - Delete event
+        if let fetchedEvent = fetchedEvent {
+            await coreDataStack.deleteObject(identifiedBy: fetchedEvent.objectID)
+        }
+
+        // Then - Verify deletion
+        let eventsAfterDeletion = try await coreDataStack.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(
+                format: "type == %@ AND timestamp == %@",
+                PumpEvent.bolus.rawValue,
+                date as NSDate
+            ),
+            key: "timestamp",
+            ascending: false
+        )
+
+        guard let eventsAfterDeletion = eventsAfterDeletion as? [PumpEventStored] else { return }
+
+        #expect(eventsAfterDeletion.isEmpty, "Should have no events after deletion")
+    }
+
+    @Test("Test store function in PumpHistoryStorage") func testStorePumpEvents() async throws {
+        // Given
+        let date = Date()
+        let tenMinAgo = date.addingTimeInterval(-10.minutes.timeInterval)
+        let halfHourInFuture = date.addingTimeInterval(30.minutes.timeInterval)
+
+        // Get initial entries to compare to final entries later
+        let initialEntries = try await testContext.perform {
+            try testContext.fetch(PumpEventStored.fetchRequest())
+        }
+
+        // Create 2 test events, 1 bolus + 1 temp basal event
+        let events: [LoopKit.NewPumpEvent] = [
+            // SMB
+            LoopKit.NewPumpEvent(
+                date: tenMinAgo,
+                dose: LoopKit.DoseEntry(
+                    type: .bolus,
+                    startDate: tenMinAgo,
+                    value: 0.4,
+                    unit: .units,
+                    deliveredUnits: nil,
+                    description: nil,
+                    syncIdentifier: nil,
+                    scheduledBasalRate: nil,
+                    insulinType: .lyumjev,
+                    automatic: true,
+                    manuallyEntered: false,
+                    isMutable: false
+                ),
+                raw: Data(),
+                title: "Test Bolus",
+                type: .bolus
+            ),
+            // Temp Basal event
+            LoopKit.NewPumpEvent(
+                date: date,
+                dose: LoopKit.DoseEntry(
+                    type: .tempBasal,
+                    startDate: date,
+                    endDate: halfHourInFuture,
+                    value: 1.2,
+                    unit: .unitsPerHour,
+                    deliveredUnits: nil,
+                    description: nil,
+                    syncIdentifier: nil,
+                    scheduledBasalRate: nil,
+                    insulinType: .lyumjev,
+                    automatic: true,
+                    manuallyEntered: false,
+                    isMutable: true
+                ),
+                raw: Data(),
+                title: "Test Temp Basal",
+                type: .tempBasal
+            )
+        ]
+
+        // When
+        // Store in our in-memory PumphistoryStorage
+        try await storage.storePumpEvents(events)
+
+        // Then
+        // Fetch all events after storing
+        let finalEntries = try await testContext.perform {
+            try testContext.fetch(PumpEventStored.fetchRequest())
+        }
+
+        // Verify there were no initial entries
+        #expect(initialEntries.isEmpty, "There should be no initial entries")
+
+        // Verify count increased by 2
+        #expect(finalEntries.count == initialEntries.count + 2, "Should have added 2 new events")
+
+        // Verify bolus event
+        let bolusEvent = finalEntries.first {
+            $0.type == PumpEvent.bolus.rawValue &&
+                abs($0.timestamp?.timeIntervalSince(tenMinAgo) ?? 1) < 1
+        }
+        #expect(bolusEvent != nil, "Should have found bolus event")
+        #expect(bolusEvent?.bolus?.amount as? Decimal == 0.4, "Bolus amount should be 0.4")
+        #expect(bolusEvent?.isUploadedToNS == false, "Should not be uploaded to NS")
+        #expect(bolusEvent?.isUploadedToHealth == false, "Should not be uploaded to Health")
+        #expect(bolusEvent?.isUploadedToTidepool == false, "Should not be uploaded to Tidepool")
+        #expect(bolusEvent?.bolus?.isSMB == true, "Should be a SMB")
+        #expect(bolusEvent?.bolus?.isExternal == false, "Should not be external insulin")
+
+        // Verify temp basal event
+        let tempBasalEvent = finalEntries.first {
+            $0.type == PumpEvent.tempBasal.rawValue &&
+                abs($0.timestamp?.timeIntervalSince(date) ?? 1) < 1
+        }
+        #expect(tempBasalEvent != nil, "Should have found temp basal event")
+        #expect(tempBasalEvent?.tempBasal?.rate as? Decimal == 1.2, "Temp basal rate should be 1.2")
+        #expect(tempBasalEvent?.tempBasal?.duration == 30, "Temp basal duration should be 30 minutes")
+        #expect(tempBasalEvent?.isUploadedToNS == false, "Should not be uploaded to NS")
+        #expect(tempBasalEvent?.isUploadedToHealth == false, "Should not be uploaded to Health")
+        #expect(bolusEvent?.isUploadedToTidepool == false, "Should not be uploaded to Tidepool")
+    }
+
+    @Test("Test store function for manual boluses") func testStorePumpEventsWithManualBoluses() async throws {
+        // Given
+        let date = Date()
+
+        // Insert mock entry
+        let events: [LoopKit.NewPumpEvent] = [
+            LoopKit.NewPumpEvent(
+                date: date,
+                dose: LoopKit.DoseEntry(
+                    type: .bolus,
+                    startDate: date,
+                    value: 4,
+                    unit: .units,
+                    deliveredUnits: nil,
+                    description: nil,
+                    syncIdentifier: nil,
+                    scheduledBasalRate: nil,
+                    insulinType: .lyumjev,
+                    automatic: false,
+                    manuallyEntered: false,
+                    isMutable: false
+                ),
+                raw: Data(),
+                title: "Test Bolus",
+                type: .bolus
+            )
+        ]
+
+        // Store test event and wait for storage to complete the task
+        try await storage.storePumpEvents(events)
+
+        // When - Fetch events with our generic fetch function
+        let fetchedEvents = try await coreDataStack.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(
+                format: "type == %@ AND timestamp == %@",
+                PumpEvent.bolus.rawValue,
+                date as NSDate
+            ),
+            key: "timestamp",
+            ascending: false
+        )
+
+        guard let fetchedEvents = fetchedEvents as? [PumpEventStored] else { return }
+
+        // Then
+        #expect(fetchedEvents.count == 1, "Should have found exactly one event")
+        let fetchedEvent = fetchedEvents.first
+        #expect(fetchedEvent?.type == PumpEvent.bolus.rawValue, "Should be a bolus event")
+        #expect(fetchedEvent?.bolus?.amount as? Decimal == 4, "Bolus amount should be 4 U")
+        #expect(
+            abs(fetchedEvent?.timestamp?.timeIntervalSince(date) ?? 1) < 1,
+            "Timestamp should match"
+        )
+        #expect(fetchedEvent?.bolus?.isSMB == false, "Should not be a SMB")
+        #expect(fetchedEvent?.bolus?.isExternal == false, "Should not be external Insulin")
+        #expect(fetchedEvent?.isUploadedToNS == false, "Should not be uploaded to NS")
+        #expect(fetchedEvent?.isUploadedToHealth == false, "Should not be uploaded to Health")
+        #expect(fetchedEvent?.isUploadedToTidepool == false, "Should not be uploaded to Tidepool")
+    }
+}

+ 148 - 0
TrioTests/CoreDataTests/TempTargetStorageTests.swift

@@ -0,0 +1,148 @@
+import CoreData
+import Foundation
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("TempTargetStorage Tests", .serialized) struct TempTargetsStorageTests: Injectable {
+    @Injected() var storage: TempTargetsStorage!
+    let resolver: Resolver
+    let coreDataStack = CoreDataStack.createForTests()
+    let testContext: NSManagedObjectContext
+
+    init() {
+        // Create test context
+        testContext = coreDataStack.newTaskContext()
+
+        // Create assembler with test assembly
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext) // Add our test assembly last to override TempTargetStorage
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+    }
+
+    @Test("Storage is correctly initialized") func testStorageInitialization() {
+        // Verify storage exists
+        #expect(storage != nil, "TempTargetsStorage should be injected")
+
+        // Verify it's the correct type
+        #expect(
+            storage is BaseTempTargetsStorage, "Storage should be of type BaseTempTargetsStorage"
+        )
+    }
+
+    @Test("Store and retrieve temp target") func testStoreAndRetrieveTempTarget() async throws {
+        // Given
+        let testTarget = TempTarget(
+            name: "Test Target",
+            createdAt: Date(),
+            targetTop: 120,
+            targetBottom: 120,
+            duration: 60,
+            enteredBy: "Test",
+            reason: "Testing",
+            isPreset: false,
+            halfBasalTarget: 160
+        )
+
+        // When
+        try await storage.storeTempTarget(tempTarget: testTarget)
+
+        // Then verify stored entries
+        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: TempTargetStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "name == %@", "Test Target"),
+            key: "date",
+            ascending: false
+        ) as? [TempTargetStored]
+
+        #expect(storedEntries?.isEmpty == false, "Should have stored entries")
+        #expect(storedEntries?.count == 1, "Should have exactly one entry")
+        let storedTarget = storedEntries?.first
+        #expect(storedTarget?.name == "Test Target", "Name should match")
+        #expect(storedTarget?.target == 120, "Target should match")
+        #expect(storedTarget?.duration == 60, "Duration should match")
+    }
+
+    @Test("Delete temp target Preset") func testDeleteTempTarget() async throws {
+        // Given
+        let testTarget = TempTarget(
+            name: "Delete Test",
+            createdAt: Date(),
+            targetTop: 120,
+            targetBottom: 120,
+            duration: 60,
+            enteredBy: "Test",
+            reason: "Testing deletion of a preset",
+            isPreset: true,
+            halfBasalTarget: 160
+        )
+        // Store the target
+        try await storage.storeTempTarget(tempTarget: testTarget)
+
+        // Get the stored target's ObjectID
+        let storedEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: TempTargetStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "name == %@", "Delete Test"),
+            key: "date",
+            ascending: false
+        ) as? [TempTargetStored]
+
+        guard let objectID = storedEntries?.first?.objectID else {
+            throw TestError("Failed to get stored target's ObjectID")
+        }
+
+        // When
+        await storage.deleteTempTargetPreset(objectID)
+
+        // Then verify deletion
+        let remainingEntries = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: TempTargetStored.self,
+            onContext: testContext,
+            predicate: NSPredicate(format: "name == %@", "Delete Test"),
+            key: "date",
+            ascending: false
+        ) as? [TempTargetStored]
+
+        #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
+    }
+
+    @Test("Get temp targets not yet uploaded to Nightscout") func testGetTempTargetsNotYetUploadedToNightscout() async throws {
+        // Given
+        let testTarget = TempTarget(
+            name: "NS Test",
+            createdAt: Date(),
+            targetTop: 120,
+            targetBottom: 120,
+            duration: 45,
+            enteredBy: "Test",
+            reason: "Testing NS Upload",
+            isPreset: true,
+            enabled: true,
+            halfBasalTarget: 160
+        )
+
+        // When
+        try await storage.storeTempTarget(tempTarget: testTarget)
+        let notUploadedTargets = try await storage.getTempTargetsNotYetUploadedToNightscout()
+
+        // Then
+        #expect(!notUploadedTargets.isEmpty, "Should have targets not uploaded to NS")
+        let target = notUploadedTargets[0]
+        #expect(target.eventType == .nsTempTarget, "Event type should be NS temp target")
+        #expect(target.duration == 45, "Duration should match")
+        #expect(target.targetTop == 120, "Target top should match target")
+        #expect(target.targetBottom == 120, "Target bottom should match target")
+    }
+}

+ 44 - 0
TrioTests/CoreDataTests/TestAssembly.swift

@@ -0,0 +1,44 @@
+import CoreData
+import Foundation
+import Swinject
+@testable import Trio
+
+class TestAssembly: Assembly {
+    private let testContext: NSManagedObjectContext
+
+    init(testContext: NSManagedObjectContext) {
+        self.testContext = testContext
+    }
+
+    func assemble(container: Container) {
+        // Override PumpHistoryStorage registration for tests
+        container.register(PumpHistoryStorage.self) { r in
+            BasePumpHistoryStorage(resolver: r, context: self.testContext)
+        }.inObjectScope(.container)
+
+        // Override DeterminationStorage registration for tests
+        container.register(DeterminationStorage.self) { r in
+            BaseDeterminationStorage(resolver: r, context: self.testContext)
+        }.inObjectScope(.container)
+
+        // Override CarbsStorage registration for tests
+        container.register(CarbsStorage.self) { r in
+            BaseCarbsStorage(resolver: r, context: self.testContext)
+        }.inObjectScope(.container)
+
+        // Override GlucoseStorage registration for tests
+        container.register(GlucoseStorage.self) { r in
+            BaseGlucoseStorage(resolver: r, context: self.testContext)
+        }.inObjectScope(.container)
+
+        // Override TempTargetStorage registration for tests
+        container.register(TempTargetsStorage.self) { r in
+            BaseTempTargetsStorage(resolver: r, context: self.testContext)
+        }.inObjectScope(.container)
+
+        // Override OverrideStorage registration for tests
+        container.register(OverrideStorage.self) { r in
+            BaseOverrideStorage(resolver: r, context: self.testContext)
+        }.inObjectScope(.container)
+    }
+}

+ 127 - 12
TrioTests/FileStorageTests.swift

@@ -1,26 +1,141 @@
+import Foundation
+import Testing
+
 @testable import Trio
-import XCTest
 
-class FileStorageTests: XCTestCase {
-    let fileStorage = BaseFileStorage()
+@Suite("File Storage Tests", .serialized) struct FileStorageTests {
+    let storage = BaseFileStorage()
 
     struct DummyObject: JSON, Equatable {
         let id: String
         let value: Decimal
     }
 
-    func testFileStorageTrio() {
-        let dummyObject = DummyObject(id: "21342Z", value: 78.2)
-        fileStorage.save(dummyObject, as: "dummyObject")
-        let dummyObjectRetrieve = fileStorage.retrieve("dummyObject", as: DummyObject.self)
-        XCTAssertTrue(dummyObject == dummyObjectRetrieve)
+    @Test("Can save and retrieve object") func testSaveAndRetrieve() {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+
+        // When
+        storage.save(dummy, as: "dummy")
+        let retrieved = storage.retrieve("dummy", as: DummyObject.self)
+
+        // Then
+        #expect(retrieved == dummy)
+    }
+
+    @Test("Can save and retrieve async") func testAsyncSaveAndRetrieve() async {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+
+        // When
+        await storage.saveAsync(dummy, as: "dummy_async")
+        let retrieved = await storage.retrieveAsync("dummy_async", as: DummyObject.self)
+
+        // Then
+        #expect(retrieved == dummy)
+    }
+
+    @Test("Can append single value") func testAppendSingleValue() {
+        // Given
+        let dummy1 = DummyObject(id: "1", value: 10.0)
+        let dummy2 = DummyObject(id: "2", value: 20.0)
+
+        // When
+        storage.save([dummy1], as: "dummies")
+        storage.append(dummy2, to: "dummies")
+
+        // Then
+        let retrieved = storage.retrieve("dummies", as: [DummyObject].self)
+        #expect(retrieved?.count == 2)
+        #expect(retrieved?.contains(dummy1) == true)
+        #expect(retrieved?.contains(dummy2) == true)
+    }
+
+    @Test("Can append multiple values") func testAppendMultipleValues() {
+        // Given
+        let dummy1 = DummyObject(id: "1", value: 10.0)
+        let newDummies = [
+            DummyObject(id: "2", value: 20.0),
+            DummyObject(id: "3", value: 30.0)
+        ]
+
+        // When
+        storage.save([dummy1], as: "dummies_multiple")
+        storage.append(newDummies, to: "dummies_multiple")
+
+        // Then
+        let retrieved = storage.retrieve("dummies_multiple", as: [DummyObject].self)
+        #expect(retrieved?.count == 3)
+    }
+
+    @Test("Can append unique values by key path") func testAppendUniqueByKeyPath() {
+        // Given
+        let dummy1 = DummyObject(id: "1", value: 10.0)
+        let dummy2 = DummyObject(id: "1", value: 20.0) // Same id
+
+        // When
+        storage.save([dummy1], as: "unique_dummies")
+        storage.append(dummy2, to: "unique_dummies", uniqBy: \.id)
+
+        // Then
+        let retrieved = storage.retrieve("unique_dummies", as: [DummyObject].self)
+        #expect(retrieved?.count == 1, "Should not append duplicate id")
+    }
+
+    @Test("Can remove file") func testRemoveFile() {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+        storage.save(dummy, as: "to_delete")
+
+        // When
+        storage.remove("to_delete")
+
+        // Then
+        let retrieved = storage.retrieve("to_delete", as: DummyObject.self)
+        #expect(retrieved == nil)
+    }
+
+    @Test("Can rename file") func testRenameFile() {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+        storage.save(dummy, as: "old_name")
+
+        // When
+        storage.rename("old_name", to: "new_name")
+
+        // Then
+        let oldRetrieved = storage.retrieve("old_name", as: DummyObject.self)
+        let newRetrieved = storage.retrieve("new_name", as: DummyObject.self)
+
+        #expect(newRetrieved == dummy)
     }
 
-    override func setUpWithError() throws {
-        // Put setup code here. This method is called before the invocation of each test method in the class.
+    @Test("Can execute transaction") func testTransaction() {
+        // Given
+        let dummy = DummyObject(id: "123", value: 78.2)
+
+        // When
+        storage.transaction { storage in
+            storage.save(dummy, as: "transaction_test")
+        }
+
+        // Then
+        let retrieved = storage.retrieve("transaction_test", as: DummyObject.self)
+        #expect(retrieved == dummy)
     }
 
-    override func tearDownWithError() throws {
-        // Put teardown code here. This method is called after the invocation of each test method in the class.
+    @Test("Can parse mmol/L settings to mg/dL") func testParseSettingsToMgdL() {
+        // Given
+        var preferences = Preferences()
+        preferences.threshold_setting = 5.5 // mmol/L
+        storage.save(preferences, as: OpenAPS.Settings.preferences)
+
+        // When
+        let wasParsed = storage.parseOnFileSettingsToMgdL()
+
+        // Then
+        #expect(wasParsed == true)
+        let parsed = storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
+        #expect(parsed?.threshold_setting == 100) // default mg/dL value
     }
 }

+ 56 - 36
TrioTests/PluginManagerTests.swift

@@ -1,71 +1,91 @@
+import Foundation
 import Swinject
+import Testing
 @testable import Trio
-import XCTest
 
-class PluginManagerTests: XCTestCase, Injectable {
+@Suite("Plugin Manager Tests", .serialized) struct PluginManagerTests: Injectable {
     let fileStorage = BaseFileStorage()
     @Injected() var pluginManager: PluginManager!
     let resolver = TrioApp().resolver
 
-    override func setUp() {
+    init() {
         injectServices(resolver)
     }
 
-    func testCGMManagerLoad() {
+    @Test("Can load CGM managers") func testCGMManagerLoad() {
+        // Given
         let cgmLoopManagers = pluginManager.availableCGMManagers
-        XCTAssertNotNil(cgmLoopManagers)
-        XCTAssertTrue(!cgmLoopManagers.isEmpty)
+
+        // Then
+        #expect(!cgmLoopManagers.isEmpty, "Should have available CGM managers")
+
+        // When loading valid CGM manager
         if let cgmLoop = cgmLoopManagers.first {
             let cgmLoopManager = pluginManager.getCGMManagerTypeByIdentifier(cgmLoop.identifier)
-            XCTAssertNotNil(cgmLoopManager)
-        } else {
-            XCTFail("Not found CGM loop manager")
+            #expect(cgmLoopManager != nil, "Should load valid CGM manager")
         }
-        /// try to load a Pump manager with a CGM identifier
+
+        // When trying to load CGM manager with pump identifier
         if let cgmLoop = cgmLoopManagers.last {
-            let cgmLoopManager = pluginManager.getPumpManagerTypeByIdentifier(cgmLoop.identifier)
-            XCTAssertNil(cgmLoopManager)
-        } else {
-            XCTFail("Not found CGM loop manager")
+            let invalidManager = pluginManager.getPumpManagerTypeByIdentifier(cgmLoop.identifier)
+            #expect(invalidManager == nil, "Should not load CGM manager with pump identifier")
         }
     }
 
-    func testPumpManagerLoad() {
+    @Test("Can load pump managers") func testPumpManagerLoad() {
+        // Given
         let pumpLoopManagers = pluginManager.availablePumpManagers
-        XCTAssertNotNil(pumpLoopManagers)
-        XCTAssertTrue(!pumpLoopManagers.isEmpty)
+
+        // Then
+        #expect(!pumpLoopManagers.isEmpty, "Should have available pump managers")
+
+        // When loading valid pump manager
         if let pumpLoop = pumpLoopManagers.first {
             let pumpLoopManager = pluginManager.getPumpManagerTypeByIdentifier(pumpLoop.identifier)
-            XCTAssertNotNil(pumpLoopManager)
-        } else {
-            XCTFail("Not found pump loop manager")
+            #expect(pumpLoopManager != nil, "Should load valid pump manager")
         }
-        /// try to load a CGM manager with a pump identifier
+
+        // When trying to load pump manager with CGM identifier
         if let pumpLoop = pumpLoopManagers.last {
-            let pumpLoopManager = pluginManager.getCGMManagerTypeByIdentifier(pumpLoop.identifier)
-            XCTAssertNil(pumpLoopManager)
-        } else {
-            XCTFail("Not found pump loop manager")
+            let invalidManager = pluginManager.getCGMManagerTypeByIdentifier(pumpLoop.identifier)
+            #expect(invalidManager == nil, "Should not load pump manager with CGM identifier")
         }
     }
 
-    func testServiceManagerLoad() {
+    @Test("Can load service managers") func testServiceManagerLoad() {
+        // Given
         let serviceManagers = pluginManager.availableServices
-        XCTAssertNotNil(serviceManagers)
-        XCTAssertTrue(!serviceManagers.isEmpty)
+
+        // Then
+        #expect(!serviceManagers.isEmpty, "Should have available services")
+
+        // When
         if let serviceLoop = serviceManagers.first {
             let serviceManager = pluginManager.getServiceTypeByIdentifier(serviceLoop.identifier)
-            XCTAssertNotNil(serviceManager)
-        } else {
-            XCTFail("Not found Service loop manager")
+            #expect(serviceManager != nil, "Should load valid service manager")
         }
     }
 
-    override func setUpWithError() throws {
-        // Put setup code here. This method is called before the invocation of each test method in the class.
-    }
+    @Test("Available managers have valid descriptors") func testManagerDescriptors() {
+        // Given/When
+        let pumpManagers = pluginManager.availablePumpManagers
+        let cgmManagers = pluginManager.availableCGMManagers
+        let serviceManagers = pluginManager.availableServices
+
+        // Then
+        for manager in pumpManagers {
+            #expect(!manager.identifier.isEmpty, "Pump manager should have non-empty identifier")
+            #expect(!manager.localizedTitle.isEmpty, "Pump manager should have non-empty title")
+        }
 
-    override func tearDownWithError() throws {
-        // Put teardown code here. This method is called after the invocation of each test method in the class.
+        for manager in cgmManagers {
+            #expect(!manager.identifier.isEmpty, "CGM manager should have non-empty identifier")
+            #expect(!manager.localizedTitle.isEmpty, "CGM manager should have non-empty title")
+        }
+
+        for manager in serviceManagers {
+            #expect(!manager.identifier.isEmpty, "Service should have non-empty identifier")
+            #expect(!manager.localizedTitle.isEmpty, "Service should have non-empty title")
+        }
     }
 }

+ 10 - 0
TrioTests/TestError.swift

@@ -0,0 +1,10 @@
+import Foundation
+
+// Custom error type for test failures
+struct TestError: Error {
+    let message: String
+
+    init(_ message: String) {
+        self.message = message
+    }
+}

+ 50 - 23
scripts/capture-build-details.sh

@@ -3,36 +3,63 @@
 #  Trio
 #
 #  Created by Jonas Björkert on 2024-05-08.
-# Enable debugging if needed
-#set -x
+
+# Path to BuildDetails.plist in the built product
 info_plist_path="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/BuildDetails.plist"
+
 # Ensure the path to BuildDetails.plist is valid.
-if [ "${info_plist_path}" == "/" -o ! -e "${info_plist_path}" ]; then
+if [ "${info_plist_path}" = "/" -o ! -e "${info_plist_path}" ]; then
     echo "BuildDetails.plist file does not exist at path: ${info_plist_path}" >&2
     exit 1
-else
-    echo "Gathering build details..."
-    # Capture the current date and write it to BuildDetails.plist
-    plutil -replace com-trio-build-date -string "$(date)" "${info_plist_path}"
+fi
+
+echo "Gathering build details..."
+
+# Capture the current date
+plutil -replace com-trio-build-date -string "$(date -u '+%a %b %e %H:%M:%S UTC %Y')" "${info_plist_path}"
 
-    # Retrieve the current branch, if available
-    git_branch=$(git symbolic-ref --short -q HEAD || echo "")
+# --- Root repo details ---
+# Retrieve current branch (or tag) and commit SHA.
+git_branch=$(git symbolic-ref --short -q HEAD || echo "")
+git_tag=$(git describe --tags --exact-match 2>/dev/null || echo "")
+git_commit_sha=$(git log -1 --format="%h" --abbrev=7)
+git_branch_or_tag="${git_branch:-${git_tag}}"
+if [ -z "${git_branch_or_tag}" ]; then
+    git_branch_or_tag="detached"
+fi
 
-    # Attempt to retrieve the current tag
-    git_tag=$(git describe --tags --exact-match 2>/dev/null || echo "")
+plutil -replace com-trio-branch -string "${git_branch_or_tag}" "${info_plist_path}"
+plutil -replace com-trio-commit-sha -string "${git_commit_sha}" "${info_plist_path}"
 
-    # Retrieve the current SHA of the latest commit
-    git_commit_sha=$(git log -1 --format="%h" --abbrev=7)
+# --- Submodule details ---
+# Remove an existing submodules key if it exists, then create an empty dictionary.
+# (Using PlistBuddy, which is available on macOS)
+submodules_key="com-trio-submodules"
+if /usr/libexec/PlistBuddy -c "Print :${submodules_key}" "${info_plist_path}" 2>/dev/null; then
+    /usr/libexec/PlistBuddy -c "Delete :${submodules_key}" "${info_plist_path}"
+fi
+/usr/libexec/PlistBuddy -c "Add :${submodules_key} dict" "${info_plist_path}"
 
-    # Determine the branch or tag information, or fallback to SHA if in detached state
-    git_branch_or_tag="${git_branch:-${git_tag}}"
-    if [ -z "${git_branch_or_tag}" ]; then
-        git_branch_or_tag="detached"
-    fi
+# Gather submodule details.
+# We use git submodule foreach to output lines in the form:
+#   submodule_name|branch_or_tag|commit_sha
+submodules_info=$(git submodule foreach --quiet '
+  sub_git_branch=$(git symbolic-ref --short -q HEAD || echo "")
+  sub_git_tag=$(git describe --tags --exact-match 2>/dev/null || echo "")
+  sub_git_commit_sha=$(git log -1 --format="%h" --abbrev=7)
+  sub_git_branch_or_tag="${sub_git_branch:-${sub_git_tag}}"
+  if [ -z "${sub_git_branch_or_tag}" ]; then
+    sub_git_branch_or_tag="detached"
+  fi
+  echo "$name|$sub_git_branch_or_tag|$sub_git_commit_sha"
+')
 
-    # Update BuildDetails.plist with the branch or tag information
-    plutil -replace com-trio-branch -string "${git_branch_or_tag}" "${info_plist_path}"
+# For each line, add a dictionary entry for that submodule.
+echo "${submodules_info}" | while IFS="|" read -r submodule_name sub_branch sub_sha; do
+    # Create a dictionary for this submodule
+    /usr/libexec/PlistBuddy -c "Add :${submodules_key}:${submodule_name} dict" "${info_plist_path}"
+    /usr/libexec/PlistBuddy -c "Add :${submodules_key}:${submodule_name}:branch string ${sub_branch}" "${info_plist_path}"
+    /usr/libexec/PlistBuddy -c "Add :${submodules_key}:${submodule_name}:commit_sha string ${sub_sha}" "${info_plist_path}"
+done
 
-    # Update BuildDetails.plist with the SHA information
-    plutil -replace com-trio-commit-sha -string "${git_commit_sha}" "${info_plist_path}"
-fi
+echo "BuildDetails.plist has been updated at: ${info_plist_path}"