ソースを参照

Merge branch 'dev' of https://github.com/nightscout/Trio-dev into stats-wip

polscm32 1 年間 前
コミット
973513c2c4

+ 1 - 3
.gitignore

@@ -79,6 +79,4 @@ fastlane/screenshots
 fastlane/test_output
 fastlane/test_output
 fastlane/FastlaneRunner
 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
             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 {
 extension TempTargetStored {

ファイルの差分が大きいため隠しています
+ 4 - 24
Trio.xcodeproj/project.pbxproj


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

@@ -945,31 +945,11 @@ final class BaseAPSManager: APSManager, Injectable {
             }
             }
             let af = pref.adjustmentFactor
             let af = pref.adjustmentFactor
             let insulin_type = pref.curve
             let insulin_type = pref.curve
-//            let buildDate = Bundle.main.buildDate // TODO: fix this
+            let buildDate = BuildDetails.default.buildDate()
             let version = Bundle.main.releaseVersionNumber
             let version = Bundle.main.releaseVersionNumber
             let build = Bundle.main.buildVersionNumber
             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 copyrightNotice_ = Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String ?? ""
             let pump_ = pumpManager?.localizedTitle ?? ""
             let pump_ = pumpManager?.localizedTitle ?? ""
@@ -1004,7 +984,7 @@ final class BaseAPSManager: APSManager, Injectable {
                 Build_Number: build ?? "1",
                 Build_Number: build ?? "1",
                 Branch: branch,
                 Branch: branch,
                 CopyRightNotice: String(copyrightNotice_.prefix(32)),
                 CopyRightNotice: String(copyrightNotice_.prefix(32)),
-                Build_Date: Date(), // TODO: fix this
+                Build_Date: buildDate ?? Date(),
                 Algorithm: algo_,
                 Algorithm: algo_,
                 AdjustmentFactor: af,
                 AdjustmentFactor: af,
                 Pump: pump_,
                 Pump: pump_,

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

@@ -10,15 +10,7 @@
 ///
 ///
 /// class GlucoseSimulatorSource - main class
 /// class GlucoseSimulatorSource - main class
 /// protocol BloodGlucoseGenerator
 /// 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 Combine
 import Foundation
 import Foundation
@@ -26,22 +18,29 @@ import LoopKitUI
 
 
 // MARK: - Glucose simulator
 // 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 {
 final class GlucoseSimulatorSource: GlucoseSource {
     var cgmManager: CGMManagerUI?
     var cgmManager: CGMManagerUI?
     var glucoseManager: FetchGlucoseManager?
     var glucoseManager: FetchGlucoseManager?
 
 
     private enum Config {
     private enum Config {
-        // min time period to publish data
+        /// Minimum time period between data publications (in seconds)
         static let workInterval: TimeInterval = 300
         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
         static let defaultBGItems = 288
     }
     }
 
 
+    /// The last glucose value that was generated
     @Persisted(key: "GlucoseSimulatorLastGlucose") private var lastGlucose = 100
     @Persisted(key: "GlucoseSimulatorLastGlucose") private var lastGlucose = 100
 
 
+    /// The date of the last fetch operation
     @Persisted(key: "GlucoseSimulatorLastFetchDate") private var lastFetchDate: Date! = nil
     @Persisted(key: "GlucoseSimulatorLastFetchDate") private var lastFetchDate: Date! = nil
 
 
+    /// Initializes the glucose simulator source
+    /// Sets up the initial fetch date if not already set
     init() {
     init() {
         if lastFetchDate == nil {
         if lastFetchDate == nil {
             var lastDate = Date()
             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 = {
     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 {
     private var canGenerateNewValues: Bool {
         guard let lastDate = lastFetchDate else { return true }
         guard let lastDate = lastFetchDate else { return true }
         if Calendar.current.dateComponents([.second], from: lastDate, to: Date()).second! >= Int(Config.workInterval) {
         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> {
     func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
         guard canGenerateNewValues else {
         guard canGenerateNewValues else {
             return Just([]).eraseToAnyPublisher()
             return Just([]).eraseToAnyPublisher()
@@ -86,6 +89,8 @@ final class GlucoseSimulatorSource: GlucoseSource {
         return Just(glucoses).eraseToAnyPublisher()
         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> {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
         fetch(nil)
     }
     }
@@ -93,105 +98,195 @@ final class GlucoseSimulatorSource: GlucoseSource {
 
 
 // MARK: - Glucose generator
 // MARK: - Glucose generator
 
 
+/// Protocol defining the interface for glucose generators
+/// Implementations of this protocol provide different strategies for generating glucose values
 protocol BloodGlucoseGenerator {
 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]
     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
+            }
+        }
     }
     }
 }
 }

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

@@ -63,9 +63,13 @@ import Swinject
     }
     }
 
 
     init() {
     init() {
+        let submodulesInfo = BuildDetails.default.submodules.map { key, value in
+            "\(key): \(value.branch) \(value.commitSHA)"
+        }.joined(separator: ", ")
+
         debug(
         debug(
             .default,
             .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
         // 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
 import Foundation
 
 
 class BuildDetails {
 class BuildDetails {
@@ -14,7 +8,7 @@ class BuildDetails {
     init() {
     init() {
         guard let url = Bundle.main.url(forResource: "BuildDetails", withExtension: "plist"),
         guard let url = Bundle.main.url(forResource: "BuildDetails", withExtension: "plist"),
               let data = try? Data(contentsOf: url),
               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 {
         else {
             dict = [:]
             dict = [:]
             return
             return
@@ -32,6 +26,21 @@ class BuildDetails {
         return "\(branch) \(sha)"
         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
     // Determine if the build is from TestFlight
     func isTestFlightBuild() -> Bool {
     func isTestFlightBuild() -> Bool {
         #if targetEnvironment(simulator)
         #if targetEnvironment(simulator)

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

@@ -12768,6 +12768,9 @@
         }
         }
       }
       }
     },
     },
+    "±" : {
+
+    },
     "<  3.3 " : {
     "<  3.3 " : {
       "extractionState" : "stale",
       "extractionState" : "stale",
       "localizations" : {
       "localizations" : {
@@ -29335,6 +29338,9 @@
         }
         }
       }
       }
     },
     },
+    "Amplitude:" : {
+
+    },
     "An example of a Carb Warning is 'Carbs required: 30 g'" : {
     "An example of a Carb Warning is 'Carbs required: 30 g'" : {
       "localizations" : {
       "localizations" : {
         "ar" : {
         "ar" : {
@@ -40968,6 +40974,9 @@
     "Category" : {
     "Category" : {
 
 
     },
     },
+    "Center Value:" : {
+
+    },
     "CGM" : {
     "CGM" : {
       "comment" : "CGM",
       "comment" : "CGM",
       "localizations" : {
       "localizations" : {
@@ -45461,6 +45470,9 @@
         }
         }
       }
       }
     },
     },
+    "Configuration changes will take effect on the next glucose reading." : {
+
+    },
     "Configure Libre Transmitter" : {
     "Configure Libre Transmitter" : {
       "extractionState" : "manual",
       "extractionState" : "manual",
       "localizations" : {
       "localizations" : {
@@ -53073,6 +53085,9 @@
         }
         }
       }
       }
     },
     },
+    "Default: 70%" : {
+
+    },
     "Default: 80%" : {
     "Default: 80%" : {
       "localizations" : {
       "localizations" : {
         "ar" : {
         "ar" : {
@@ -82008,6 +82023,9 @@
         }
         }
       }
       }
     },
     },
+    "Glucose trace WILL NOT be affected by any insulin or carb entries." : {
+
+    },
     "Glucose Trend" : {
     "Glucose Trend" : {
       "localizations" : {
       "localizations" : {
         "ar" : {
         "ar" : {
@@ -98593,7 +98611,7 @@
         "de" : {
         "de" : {
           "stringUnit" : {
           "stringUnit" : {
             "state" : "translated",
             "state" : "translated",
-            "value" : "Holzkohle"
+            "value" : "Kohlenhydrate hinzufügen"
           }
           }
         },
         },
         "es" : {
         "es" : {
@@ -113019,6 +113037,9 @@
     "No Temp Target Presets" : {
     "No Temp Target Presets" : {
 
 
     },
     },
+    "Noise:" : {
+
+    },
     "Noisy CGM Target Increase" : {
     "Noisy CGM Target Increase" : {
       "comment" : "Noisy CGM Target Increase",
       "comment" : "Noisy CGM Target Increase",
       "localizations" : {
       "localizations" : {
@@ -122320,6 +122341,9 @@
         }
         }
       }
       }
     },
     },
+    "Period:" : {
+
+    },
     "Persist sensordata" : {
     "Persist sensordata" : {
       "extractionState" : "manual",
       "extractionState" : "manual",
       "localizations" : {
       "localizations" : {
@@ -124307,6 +124331,9 @@
     "Processing…" : {
     "Processing…" : {
 
 
     },
     },
+    "Produce Stale Values" : {
+
+    },
     "profile" : {
     "profile" : {
       "localizations" : {
       "localizations" : {
         "ar" : {
         "ar" : {
@@ -126815,9 +126842,22 @@
         }
         }
       }
       }
     },
     },
+    "Random variation added to each reading to simulate real-world sensor noise." : {
+
+    },
     "Range" : {
     "Range" : {
 
 
     },
     },
+    "Range: %@–%@ %@" : {
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "Range: %1$@–%2$@ %3$@"
+          }
+        }
+      }
+    },
     "Rapid-Acting: 75 minutes (permitted range 50-120 minutes)" : {
     "Rapid-Acting: 75 minutes (permitted range 50-120 minutes)" : {
       "localizations" : {
       "localizations" : {
         "ar" : {
         "ar" : {
@@ -129510,6 +129550,9 @@
         }
         }
       }
       }
     },
     },
+    "Reset to Defaults" : {
+
+    },
     "Resistance Lowers Target" : {
     "Resistance Lowers Target" : {
       "comment" : "Resistance Lowers Target",
       "comment" : "Resistance Lowers Target",
       "localizations" : {
       "localizations" : {
@@ -146855,6 +146898,9 @@
         }
         }
       }
       }
     },
     },
+    "Submodules" : {
+
+    },
     "Subtract IOB" : {
     "Subtract IOB" : {
       "localizations" : {
       "localizations" : {
         "ar" : {
         "ar" : {
@@ -151423,6 +151469,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." : {
     "The current version has a critical issue and should be updated as soon as possible." : {
       "comment" : "Message for critical update alert"
       "comment" : "Message for critical update alert"
     },
     },
@@ -153568,6 +153617,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)" : {
     "The maximum duration for tracking carb entries in estimating Carbs on Board (COB)" : {
 
 
     },
     },
@@ -154533,6 +154585,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." : {
     "The source of the glucose reading will be added to the notification." : {
       "localizations" : {
       "localizations" : {
         "ar" : {
         "ar" : {
@@ -154639,6 +154694,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:" : {
     "The Upload Treatments toggle enables uploading of the following data sets to your connected Nightscout URL:" : {
       "localizations" : {
       "localizations" : {
         "ar" : {
         "ar" : {
@@ -168976,9 +169034,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." : {
     "Trio's Simple Lock Screen Widget displays current glucose reading, trend arrow, delta and the timestamp of the current reading." : {
       "localizations" : {
       "localizations" : {
         "ar" : {
         "ar" : {
@@ -177134,6 +177189,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." : {
     "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" : {
       "localizations" : {
         "ar" : {
         "ar" : {

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

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

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

@@ -252,8 +252,11 @@ struct AddTempTargetForm: View {
                             do {
                             do {
                                 if noNameSpecified { state.tempTargetName = "Custom Target" }
                                 if noNameSpecified { state.tempTargetName = "Custom Target" }
                                 didPressSave.toggle()
                                 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()
                                 dismiss()
+
+                                try await state.invokeSaveOfCustomTempTargets()
                             } catch {
                             } catch {
                                 debug(.default, "\(DebuggingIdentifiers.failed) failed to save custom temp target: \(error)")
                                 debug(.default, "\(DebuggingIdentifiers.failed) failed to save custom temp target: \(error)")
                             }
                             }

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

@@ -174,7 +174,7 @@ extension AutosensSettings {
                     miniHint: "Lower limit of the Autosens Ratio.",
                     miniHint: "Lower limit of the Autosens Ratio.",
                     verboseHint:
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                     VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: 80%").bold()
+                        Text("Default: 70%").bold()
                         Text(
                         Text(
                             "Autosens Min sets the minimum Autosens Ratio used by Autosens, Dynamic ISF, and Sigmoid Formula."
                             "Autosens Min sets the minimum Autosens Ratio used by Autosens, Dynamic ISF, and Sigmoid Formula."
                         )
                         )

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

@@ -15,14 +15,49 @@ extension CGMSettings {
 
 
         @State private var shouldDisplayDeletionConfirmation: Bool = false
         @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 {
         var body: some View {
             NavigationView {
             NavigationView {
                 Form {
                 Form {
                     if cgmCurrent.type != .none {
                     if cgmCurrent.type != .none {
                         if cgmCurrent.type == .nightscout {
                         if cgmCurrent.type == .nightscout {
                             nightscoutSection
                             nightscoutSection
-                        } else {
-                            customCGMSection
+                        } else if cgmCurrent.type == .xdrip {
+                            xDripConfigurationSection
+                        } else if cgmCurrent.type == .simulator {
+                            simulatorConfigurationSection
                         }
                         }
 
 
                         if let appURL = cgmCurrent.type.appURL {
                         if let appURL = cgmCurrent.type.appURL {
@@ -64,7 +99,7 @@ extension CGMSettings {
                 }
                 }
                 .safeAreaInset(
                 .safeAreaInset(
                     edge: .bottom,
                     edge: .bottom,
-                    spacing: 30
+                    spacing: 0
                 ) {
                 ) {
                     stickyDeleteButton
                     stickyDeleteButton
                 }
                 }
@@ -79,6 +114,11 @@ extension CGMSettings {
                             .tint(.red)
                             .tint(.red)
                     }
                     }
                 } message: { Text("Are you sure you want to delete \(cgmCurrent.displayName)?") }
                 } 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." :
                                     "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."
                                     "Tap the button below to open your Nightscout instance in your iPhone's default browser."
                             ).font(.footnote)
                             ).font(.footnote)
-                                .foregroundColor(.secondary)
+                                .foregroundStyle(Color.secondary)
                                 .lineLimit(nil)
                                 .lineLimit(nil)
                                 .padding(.vertical)
                                 .padding(.vertical)
                         }
                         }
@@ -136,61 +176,242 @@ extension CGMSettings {
             }
             }
         }
         }
 
 
-        var customCGMSection: some View {
+        var xDripConfigurationSection: some View {
             Section(
             Section(
                 header: Text("Configuration"),
                 header: Text("Configuration"),
                 content: {
                 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(
                         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 {
                             HStack {
-                                Text("About this source")
+                                Text("Center Value:").bold()
+
                                 Spacer()
                                 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 {
         var stickyDeleteButton: some View {
             ZStack {
             ZStack {
                 Rectangle()
                 Rectangle()
-                    .frame(width: UIScreen.main.bounds.width, height: 65)
+                    .frame(width: UIScreen.main.bounds.width, height: 120)
                     .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white)
                     .foregroundStyle(colorScheme == .dark ? Color.bgDarkerDarkBlue : Color.white)
                     .background(.thinMaterial)
                     .background(.thinMaterial)
                     .opacity(0.8)
                     .opacity(0.8)
                     .clipShape(Rectangle())
                     .clipShape(Rectangle())
+                    .padding(.bottom, -55)
 
 
                 Button(action: {
                 Button(action: {
                     shouldDisplayDeletionConfirmation.toggle()
                     shouldDisplayDeletionConfirmation.toggle()

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

@@ -106,14 +106,21 @@ extension DataTable {
         func invokeCarbDeletionTask(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) {
         func invokeCarbDeletionTask(_ treatmentObjectID: NSManagedObjectID, isFpuOrComplexMeal: Bool = false) {
             Task {
             Task {
                 do {
                 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 {
                     await MainActor.run {
                         carbEntryDeleted = true
                         carbEntryDeleted = true
                         waitForSuggestion = true
                         waitForSuggestion = true
                     }
                     }
+
+                    try await deleteCarbs(treatmentObjectID, isFpuOrComplexMeal: isFpuOrComplexMeal)
+
                 } catch {
                 } catch {
                     debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete carbs: \(error.localizedDescription)")
                     debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete carbs: \(error.localizedDescription)")
+                    await MainActor.run {
+                        carbEntryDeleted = false
+                        waitForSuggestion = false
+                    }
                 }
                 }
             }
             }
         }
         }
@@ -214,11 +221,6 @@ extension DataTable {
             Task {
             Task {
                 do {
                 do {
                     try await invokeInsulinDeletion(treatmentObjectID)
                     try await invokeInsulinDeletion(treatmentObjectID)
-
-                    await MainActor.run {
-                        insulinEntryDeleted = true
-                        waitForSuggestion = true
-                    }
                 } catch {
                 } catch {
                     debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete insulin entry: \(error)")
                     debug(.default, "\(DebuggingIdentifiers.failed) Failed to delete insulin entry: \(error)")
                 }
                 }
@@ -234,6 +236,16 @@ extension DataTable {
                     return
                     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)
                 // Delete from remote service(s) (i.e. Nightscout, Apple Health, Tidepool)
                 await deleteInsulinFromServices(with: treatmentObjectID)
                 await deleteInsulinFromServices(with: treatmentObjectID)
 
 
@@ -246,6 +258,10 @@ extension DataTable {
                 debugPrint(
                 debugPrint(
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error.localizedDescription)"
                     "\(DebuggingIdentifiers.failed) \(#file) \(#function) Error while Insulin Deletion Task: \(error.localizedDescription)"
                 )
                 )
+                await MainActor.run {
+                    insulinEntryDeleted = false
+                    waitForSuggestion = false
+                }
             }
             }
         }
         }
 
 

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

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

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

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

+ 2 - 2
Trio/Sources/Modules/TargetBehavoir/View/TargetBehavoirRootView.swift

@@ -40,12 +40,12 @@ extension TargetBehavoir {
                         "High Temp Target Raises Sensitivity",
                         "High Temp Target Raises Sensitivity",
                         comment: "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: "Increase sensitivity when glucose is above target if a manual Temp Target > \(state.units == .mgdL ? "100" : 100.formattedAsMmolL) \(state.units.rawValue) is set.",
                     verboseHint:
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
                         Text("Default: OFF").bold()
                         Text(
                         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(
                         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."
                             "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."

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

@@ -80,15 +80,16 @@ extension Treatments {
             HStack {
             HStack {
                 HStack {
                 HStack {
                     Text("Protein")
                     Text("Protein")
-
                     TextFieldWithToolBar(
                     TextFieldWithToolBar(
                         text: $state.protein,
                         text: $state.protein,
                         placeholder: "0",
                         placeholder: "0",
                         keyboardType: .numberPad,
                         keyboardType: .numberPad,
                         numberFormatter: mealFormatter,
                         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)
                     Text("g").foregroundColor(.secondary)
                 }
                 }
 
 
@@ -101,9 +102,11 @@ extension Treatments {
                         placeholder: "0",
                         placeholder: "0",
                         keyboardType: .numberPad,
                         keyboardType: .numberPad,
                         numberFormatter: mealFormatter,
                         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)
                     Text("g").foregroundColor(.secondary)
                 }
                 }
             }
             }
@@ -118,39 +121,60 @@ extension Treatments {
                     placeholder: "0",
                     placeholder: "0",
                     keyboardType: .numberPad,
                     keyboardType: .numberPad,
                     numberFormatter: mealFormatter,
                     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)
                 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,
                                     textColor: colorScheme == .dark ? .white : .blue,
                                     maxLength: 5,
                                     maxLength: 5,
                                     numberFormatter: formatter,
                                     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)
                                 ).focused($focusedField, equals: .bolus)
                                     .onChange(of: state.amount) {
                                     .onChange(of: state.amount) {
                                         Task {
                                         Task {

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

@@ -246,7 +246,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                     }
                     }
 
 
                     let iobValue = latestDetermination.iob ?? 0
                     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)
                     let cobNumber = NSNumber(value: latestDetermination.cob)
                     watchState.cob = Formatter.integerFormatter.string(from: cobNumber)
                     watchState.cob = Formatter.integerFormatter.string(from: cobNumber)
@@ -256,15 +256,13 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
 
 
                     if self.units == .mgdL {
                     if self.units == .mgdL {
                         watchState.isf = insulinSensitivity.description
                         watchState.isf = insulinSensitivity.description
-                        watchState.eventualBGRaw = Formatter.glucoseFormatter(for: self.units)
-                            .string(from: eventualBG) ?? "0"
+                        watchState.eventualBGRaw = eventualBG.description
                     } else {
                     } else {
                         let parsedIsf = Double(truncating: insulinSensitivity).asMmolL
                         let parsedIsf = Double(truncating: insulinSensitivity).asMmolL
                         let parsedEventualBG = Double(truncating: eventualBG).asMmolL
                         let parsedEventualBG = Double(truncating: eventualBG).asMmolL
 
 
                         watchState.isf = parsedIsf.description
                         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
                         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)"
                     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
 // MARK: - Extensions

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

@@ -57,5 +57,14 @@ struct AppShortcuts: AppShortcutsProvider {
             shortTitle: "Cancel Override",
             shortTitle: "Cancel Override",
             systemImageName: "xmark.circle.fill"
             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 textFieldDidBeginEditing: (() -> Void)?
     var numberFormatter: NumberFormatter
     var numberFormatter: NumberFormatter
     var allowDecimalSeparator: Bool
     var allowDecimalSeparator: Bool
+    var showArrows: Bool
     var previousTextField: (() -> Void)?
     var previousTextField: (() -> Void)?
     var nextTextField: (() -> Void)?
     var nextTextField: (() -> Void)?
 
 
@@ -32,6 +33,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
         textFieldDidBeginEditing: (() -> Void)? = nil,
         textFieldDidBeginEditing: (() -> Void)? = nil,
         numberFormatter: NumberFormatter,
         numberFormatter: NumberFormatter,
         allowDecimalSeparator: Bool = true,
         allowDecimalSeparator: Bool = true,
+        showArrows: Bool = false,
         previousTextField: (() -> Void)? = nil,
         previousTextField: (() -> Void)? = nil,
         nextTextField: (() -> Void)? = nil
         nextTextField: (() -> Void)? = nil
     ) {
     ) {
@@ -49,6 +51,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
         self.numberFormatter = numberFormatter
         self.numberFormatter = numberFormatter
         self.numberFormatter.numberStyle = .decimal
         self.numberFormatter.numberStyle = .decimal
         self.allowDecimalSeparator = allowDecimalSeparator
         self.allowDecimalSeparator = allowDecimalSeparator
+        self.showArrows = showArrows
         self.previousTextField = previousTextField
         self.previousTextField = previousTextField
         self.nextTextField = nextTextField
         self.nextTextField = nextTextField
     }
     }
@@ -56,7 +59,7 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
     public func makeUIView(context: Context) -> UITextField {
     public func makeUIView(context: Context) -> UITextField {
         let textField = UITextField()
         let textField = UITextField()
         context.coordinator.textField = textField
         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.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
         textField.delegate = context.coordinator
         textField.delegate = context.coordinator
         if text == 0 { /// show no value initially, i.e. empty String
         if text == 0 { /// show no value initially, i.e. empty String
@@ -68,36 +71,63 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
         return textField
         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()
         toolbar.sizeToFit()
+
         return toolbar
         return toolbar
     }
     }
 
 
@@ -185,6 +215,16 @@ public struct TextFieldWithToolBar: UIViewRepresentable {
 }
 }
 
 
 extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
 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(
     public func textField(
         _ textField: UITextField,
         _ textField: UITextField,
         shouldChangeCharactersIn range: NSRange,
         shouldChangeCharactersIn range: NSRange,
@@ -192,52 +232,57 @@ extension TextFieldWithToolBar.Coordinator: UITextFieldDelegate {
     ) -> Bool {
     ) -> Bool {
         // Check if the input is a number or the decimal separator
         // Check if the input is a number or the decimal separator
         let isNumber = CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: string))
         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?
            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
                 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) {
     public func textFieldDidBeginEditing(_: UITextField) {
@@ -254,7 +299,7 @@ extension UITextField {
 }
 }
 
 
 extension UIApplication {
 extension UIApplication {
-    func endEditing() {
+    @objc func endEditing() {
         sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
         sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
     }
     }
 }
 }
@@ -273,7 +318,7 @@ public struct TextFieldWithToolBarString: UIViewRepresentable {
     public func makeUIView(context: Context) -> UITextField {
     public func makeUIView(context: Context) -> UITextField {
         let textField = UITextField()
         let textField = UITextField()
         context.coordinator.textField = textField
         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.addTarget(context.coordinator, action: #selector(Coordinator.editingDidBegin), for: .editingDidBegin)
         textField.delegate = context.coordinator
         textField.delegate = context.coordinator
         textField.text = text
         textField.text = text
@@ -286,7 +331,12 @@ public struct TextFieldWithToolBarString: UIViewRepresentable {
         return textField
         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 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 flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
         let doneButton = UIBarButtonItem(
         let doneButton = UIBarButtonItem(

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

@@ -3,36 +3,63 @@
 #  Trio
 #  Trio
 #
 #
 #  Created by Jonas Björkert on 2024-05-08.
 #  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"
 info_plist_path="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/BuildDetails.plist"
+
 # Ensure the path to BuildDetails.plist is valid.
 # 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
     echo "BuildDetails.plist file does not exist at path: ${info_plist_path}" >&2
     exit 1
     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}"