Browse Source

Fixed merge conflicts

Sam King 8 months ago
parent
commit
a0f9c1fdab
40 changed files with 910 additions and 409 deletions
  1. 1 1
      Config.xcconfig
  2. 1 1
      DanaKit
  3. 15 3
      Trio.xcodeproj/project.pbxproj
  4. 9 0
      Trio.xcodeproj/xcshareddata/xcschemes/Trio Tests.xcscheme
  5. 6 0
      Trio/Sources/APS/APSManager.swift
  6. 1 0
      Trio/Sources/Application/TrioApp.swift
  7. 1 0
      Trio/Sources/Assemblies/ServiceAssembly.swift
  8. 4 1
      Trio/Sources/Helpers/Formatters.swift
  9. 76 17
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  10. 2 2
      Trio/Sources/Modules/Calibrations/View/CalibrationsRootView.swift
  11. 6 6
      Trio/Sources/Modules/DataTable/View/CarbEntryEditorView.swift
  12. 2 2
      Trio/Sources/Modules/DataTable/View/DataTableRootView.swift
  13. 1 1
      Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift
  14. 13 0
      Trio/Sources/Modules/Home/HomeStateModel.swift
  15. 1 1
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  16. 2 2
      Trio/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift
  17. 1 1
      Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift
  18. 49 18
      Trio/Sources/Modules/Settings/View/SettingsRootView.swift
  19. 8 2
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift
  20. 11 5
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift
  21. 8 2
      Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift
  22. 8 19
      Trio/Sources/Modules/Treatments/View/ForecastChart.swift
  23. 6 6
      Trio/Sources/Modules/Treatments/View/MealPreset/AddMealPresetView.swift
  24. 8 8
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  25. 307 152
      Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift
  26. 2 1
      Trio/Sources/Services/Calendar/CalendarManager.swift
  27. 13 1
      Trio/Sources/Services/ContactImage/ContactImageManager.swift
  28. 115 0
      Trio/Sources/Services/IOB/IOBService.swift
  29. 1 2
      Trio/Sources/Services/LiveActivity/Data/DataManager.swift
  30. 0 1
      Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift
  31. 2 1
      Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift
  32. 10 0
      Trio/Sources/Services/LiveActivity/LiveActivityManager.swift
  33. 3 25
      Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift
  34. 1 0
      Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift
  35. 45 4
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  36. 35 3
      Trio/Sources/Services/WatchManager/GarminManager.swift
  37. 1 0
      Trio/Sources/Shortcuts/BaseIntentsRequest.swift
  38. 5 5
      Trio/Sources/Shortcuts/Carbs/AddCarbPresetIntent.swift
  39. 1 1
      Trio/Sources/Shortcuts/State/StateIntentRequest.swift
  40. 129 115
      Trio/Sources/Views/TextFieldWithToolBar.swift

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.5.1
-APP_DEV_VERSION = 0.5.1.11
+APP_DEV_VERSION = 0.5.1.22
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
DanaKit

@@ -1 +1 @@
-Subproject commit 1ea5e384c88f4ff51c7679fea4e17fe13c279d40
+Subproject commit 33a8d4705fc82b371daf4bd5977ed2cfaf420204

+ 15 - 3
Trio.xcodeproj/project.pbxproj

@@ -268,6 +268,7 @@
 		3B4BA7902D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */; };
 		3B4BA7912D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		3B4D17132E1D8A0D007FB180 /* AutosensJsonExtraTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4D17122E1D89FE007FB180 /* AutosensJsonExtraTests.swift */; };
+		3B506FD92E635304000740B9 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B506FD82E635304000740B9 /* IOBService.swift */; };
 		3B5CD1EC2D4912A600CE213C /* OpenAPSSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD1E92D4912A600CE213C /* OpenAPSSwift.swift */; };
 		3B5CD1ED2D4912A600CE213C /* JSONBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD1EA2D4912A600CE213C /* JSONBridge.swift */; };
 		3B5CD2982D4AEA3C00CE213C /* Carbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2922D4AEA3C00CE213C /* Carbs.swift */; };
@@ -1195,6 +1196,7 @@
 		3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TidepoolServiceKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TidepoolServiceKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3B4D17122E1D89FE007FB180 /* AutosensJsonExtraTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutosensJsonExtraTests.swift; sourceTree = "<group>"; };
+		3B506FD82E635304000740B9 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = "<group>"; };
 		3B5CD1E92D4912A600CE213C /* OpenAPSSwift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAPSSwift.swift; sourceTree = "<group>"; };
 		3B5CD1EA2D4912A600CE213C /* JSONBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONBridge.swift; sourceTree = "<group>"; };
 		3B5CD2912D4AEA3C00CE213C /* Basal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Basal.swift; sourceTree = "<group>"; };
@@ -2236,17 +2238,18 @@
 		3811DE9125C9D88200A708ED /* Services */ = {
 			isa = PBXGroup;
 			children = (
-				BD47FD112D88AA630043966B /* OnboardingManager */,
-				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
-				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
 				3811DE9225C9D88200A708ED /* Appearance */,
+				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
+				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
 				3862CC2C2743F9DC00BF832C /* Calendar */,
 				E592A37E2CEEC046009A472C /* ContactImage */,
 				F90692A8274B7A980037068D /* HealthKit */,
+				3B506FD72E6352E9000740B9 /* IOB */,
 				6B1A8D2C2B156EC100E76752 /* LiveActivity */,
 				3811DE9425C9D88200A708ED /* Network */,
 				38B4F3C425E5016800E76A18 /* Notifications */,
+				BD47FD112D88AA630043966B /* OnboardingManager */,
 				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				3811DE9825C9D88300A708ED /* Storage */,
@@ -2855,6 +2858,14 @@
 			path = utils;
 			sourceTree = "<group>";
 		};
+		3B506FD72E6352E9000740B9 /* IOB */ = {
+			isa = PBXGroup;
+			children = (
+				3B506FD82E635304000740B9 /* IOBService.swift */,
+			);
+			path = IOB;
+			sourceTree = "<group>";
+		};
 		3B5CD1EB2D4912A600CE213C /* OpenAPSSwift */ = {
 			isa = PBXGroup;
 			children = (
@@ -5061,6 +5072,7 @@
 				DD30786A2D42F94000DE0490 /* GarminDevice.swift in Sources */,
 				38E4451E274DB04600EC9A94 /* AppDelegate.swift in Sources */,
 				BD2FF1A02AE29D43005D1C5D /* ToggleStyles.swift in Sources */,
+				3B506FD92E635304000740B9 /* IOBService.swift in Sources */,
 				DDD163162C4C690300CD525A /* AdjustmentsDataFlow.swift in Sources */,
 				BDF34F932C10D0E100D51995 /* LiveActivityAttributes+Helper.swift in Sources */,
 				E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */,

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

@@ -53,6 +53,15 @@
       savedToolIdentifier = ""
       useCustomWorkingDirectory = "NO"
       debugDocumentVersioning = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "388E595725AD948C0019842D"
+            BuildableName = "Trio.app"
+            BlueprintName = "Trio"
+            ReferencedContainer = "container:Trio.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
    </ProfileAction>
    <AnalyzeAction
       buildConfiguration = "Debug">

+ 6 - 0
Trio/Sources/APS/APSManager.swift

@@ -30,6 +30,7 @@ protocol APSManager {
     func roundBolus(amount: Decimal) -> Decimal
     var lastError: CurrentValueSubject<Error?, Never> { get }
     func cancelBolus(_ callback: ((Bool, String) -> Void)?) async
+    var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
 }
 
 enum APSError: LocalizedError {
@@ -107,6 +108,7 @@ final class BaseAPSManager: APSManager, Injectable {
     let isLooping = CurrentValueSubject<Bool, Never>(false)
     let lastLoopDateSubject = PassthroughSubject<Date, Never>()
     let lastError = CurrentValueSubject<Error?, Never>(nil)
+    let iobFileDidUpdate = PassthroughSubject<Void, Never>()
 
     let bolusProgress = CurrentValueSubject<Decimal?, Never>(nil)
 
@@ -464,6 +466,8 @@ final class BaseAPSManager: APSManager, Injectable {
                 useSwiftOref: settings.useSwiftOref
             )
 
+            iobFileDidUpdate.send(())
+
             guard isValidGlucoseData else {
                 throw APSError.glucoseError(message: "Glucose validation failed")
             }
@@ -478,6 +482,8 @@ final class BaseAPSManager: APSManager, Injectable {
                 }
             }
         } catch {
+            iobFileDidUpdate.send(())
+
             // if we have a glucose validation error we might still run
             // determineBasal to try to get IoB and CoB updates but we
             // know that it will fail, so the invalidGlucoseError always

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

@@ -84,6 +84,7 @@ extension Notification.Name {
         if #available(iOS 16.2, *) {
             _ = resolver.resolve(LiveActivityManager.self)!
         }
+        _ = resolver.resolve(IOBService.self)!
     }
 
     init() {

+ 1 - 0
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -28,5 +28,6 @@ final class ServiceAssembly: Assembly {
                 LiveActivityManager(resolver: r)
             }
         }
+        container.register(IOBService.self) { r in BaseIOBService(resolver: r) }
     }
 }

+ 4 - 1
Trio/Sources/Helpers/Formatters.swift

@@ -32,6 +32,7 @@ extension Formatter {
     static let decimalFormatterWithTwoFractionDigits: NumberFormatter = {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
+        formatter.locale = .current
         formatter.maximumFractionDigits = 2
         return formatter
     }()
@@ -51,6 +52,7 @@ extension Formatter {
     static let decimalFormatterWithOneFractionDigit: NumberFormatter = {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
+        formatter.locale = .current
         formatter.maximumFractionDigits = 1
         return formatter
     }()
@@ -69,6 +71,7 @@ extension Formatter {
 
         switch units {
         case .mmolL:
+            formatter.locale = .current
             formatter.minimumFractionDigits = 1
             formatter.maximumFractionDigits = 1
         case .mgdL:
@@ -81,9 +84,9 @@ extension Formatter {
     static let bolusFormatter: NumberFormatter = {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
+        formatter.locale = .current
         formatter.minimumIntegerDigits = 0
         formatter.maximumFractionDigits = 2
-        formatter.decimalSeparator = "."
         return formatter
     }()
 

+ 76 - 17
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -6430,6 +6430,9 @@
         }
       }
     },
+    "%@ " : {
+
+    },
     "%@  %@" : {
       "localizations" : {
         "bg" : {
@@ -7752,6 +7755,7 @@
     },
     "%@ U" : {
       "comment" : "Number of units insulin delivered",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -13717,7 +13721,7 @@
         }
       }
     },
-    "• Manual boluses you enter yoursef" : {
+    "• Manual boluses you enter yourself" : {
 
     },
     "• Manual temporary basal rates you set yourself" : {
@@ -20554,17 +20558,6 @@
         }
       }
     },
-    "A external bolus of %@ U of insulin was recorded." : {
-      "extractionState" : "stale",
-      "localizations" : {
-        "fr" : {
-          "stringUnit" : {
-            "state" : "translated",
-            "value" : "Un bolus externe de %@ U d'insuline a été enregistré."
-          }
-        }
-      }
-    },
     "A few important notes…" : {
       "localizations" : {
         "bg" : {
@@ -37418,7 +37411,14 @@
       }
     },
     "An external bolus of %@ U of insulin was recorded." : {
-
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Un bolus externe de %@ U d'insuline a été enregistré."
+          }
+        }
+      }
     },
     "An unknown authentication error occurred. Please try again." : {
       "localizations" : {
@@ -39338,6 +39338,7 @@
       }
     },
     "Applying ${carbQuantity} at ${dateAdded}" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -62240,6 +62241,16 @@
         }
       }
     },
+    "Confirm Before logging" : {
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Confirmer avant d’enregistrer"
+          }
+        }
+      }
+    },
     "Confirm Bolus Faster" : {
       "localizations" : {
         "bg" : {
@@ -102976,7 +102987,7 @@
       }
     },
     "g" : {
-      "comment" : "Gram abbreviation\nThe short unit display string for grams\ngram of carbs",
+      "comment" : "Gram abbreviation\nThe short unit display string for grams\nUnits for carbs\ngram of carbs",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -115085,6 +115096,16 @@
         }
       }
     },
+    "If toggled, you will need to confirm before logging" : {
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Si coché, vous devez confirmer avant l’enregistrement"
+          }
+        }
+      }
+    },
     "If using Dynamic ISF (with Sigmoid), overriding your ISF will adjust the ISF used at your glucose target which extends to the ISF used at other glucose. Overriding your glucose target will change glucose level your ISF will be set to your profile ISF. Both of these can be combined in a single Override." : {
       "localizations" : {
         "bg" : {
@@ -116370,6 +116391,7 @@
       }
     },
     "Immediately applying ${carbQuantity} at ${dateAdded}" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -116687,6 +116709,16 @@
         }
       }
     },
+    "Immediately Log ${carbQuantity} at ${dateAdded}" : {
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Enregistrer immédiatement ${carbQuantity} à ${dateAdded}"
+          }
+        }
+      }
+    },
     "Import Error" : {
       "comment" : "Import Error HeadlineImport Error Headline",
       "extractionState" : "manual",
@@ -124708,6 +124740,12 @@
         }
       }
     },
+    "Latest dev: %@" : {
+
+    },
+    "Latest dev: Fetching..." : {
+
+    },
     "Latest Raw Algorithm Output" : {
       "localizations" : {
         "bg" : {
@@ -128269,6 +128307,16 @@
         }
       }
     },
+    "Log ${carbQuantity} at ${dateAdded}" : {
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Enregistrer ${carbQuantity} à${dateAdded}"
+          }
+        }
+      }
+    },
     "Log Carbs" : {
       "comment" : "Button Label to Log Carbs on Watch",
       "localizations" : {
@@ -167885,6 +167933,7 @@
       }
     },
     "Quantity fat" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -167990,6 +168039,9 @@
         }
       }
     },
+    "Quantity Fat" : {
+
+    },
     "Quantity of carbs in g" : {
       "localizations" : {
         "bg" : {
@@ -202295,7 +202347,14 @@
       }
     },
     "The external bolus cannot be larger than 3 x the pump setting max bolus (%@)." : {
-
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Le bolus externe ne peut pas être supérieur à 3 x le réglage maximum de l'bolus de la pompe (%@)."
+          }
+        }
+      }
     },
     "The Fat and Protein Delay setting defines the time between when you log fat and protein and when the system starts delivering insulin for the Fat-Protein Unit Carb Equivalents (FPUs)." : {
       "localizations" : {
@@ -223943,7 +224002,7 @@
       }
     },
     "U" : {
-      "comment" : "Insulin unit\nInsulin unit abbreviation\nThe short unit display string for international units of insulin",
+      "comment" : "Insulin unit\nInsulin unit abbreviation\nThe short unit display string for international units of insulin\nUnits for bolus amount",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -224156,7 +224215,7 @@
       }
     },
     "U/hr" : {
-      "comment" : "Insulin unit per hour abbreviation",
+      "comment" : "Insulin unit per hour abbreviation\nUnits text for temporary basal rate",
       "localizations" : {
         "bg" : {
           "stringUnit" : {

+ 2 - 2
Trio/Sources/Modules/Calibrations/View/CalibrationsRootView.swift

@@ -48,9 +48,9 @@ extension Calibrations {
                             TextFieldWithToolBar(
                                 text: $state.newCalibration,
                                 placeholder: "0",
-                                numberFormatter: manualGlucoseFormatter
+                                numberFormatter: manualGlucoseFormatter,
+                                unitsText: state.units.rawValue
                             )
-                            Text(state.units.rawValue).foregroundColor(.secondary)
                         }
                         Button {
                             Task {

+ 6 - 6
Trio/Sources/Modules/DataTable/View/CarbEntryEditorView.swift

@@ -138,9 +138,9 @@ struct CarbEntryEditorView: View {
                             text: $editedCarbs,
                             placeholder: "0",
                             keyboardType: .numberPad,
-                            numberFormatter: mealFormatter
+                            numberFormatter: mealFormatter,
+                            unitsText: String(localized: "g", comment: "Units for carbs")
                         )
-                        Text("g").foregroundStyle(.secondary)
                     }
 
                     if state.settingsManager.settings.useFPUconversion {
@@ -150,9 +150,9 @@ struct CarbEntryEditorView: View {
                                 text: $editedProtein,
                                 placeholder: "0",
                                 keyboardType: .numberPad,
-                                numberFormatter: mealFormatter
+                                numberFormatter: mealFormatter,
+                                unitsText: String(localized: "g", comment: "Units for carbs")
                             )
-                            Text("g").foregroundStyle(.secondary)
                         }
 
                         HStack {
@@ -161,9 +161,9 @@ struct CarbEntryEditorView: View {
                                 text: $editedFat,
                                 placeholder: "0",
                                 keyboardType: .numberPad,
-                                numberFormatter: mealFormatter
+                                numberFormatter: mealFormatter,
+                                unitsText: String(localized: "g", comment: "Units for carbs")
                             )
-                            Text("g").foregroundStyle(.secondary)
                         }
                     }
 

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

@@ -440,9 +440,9 @@ extension DataTable {
                                     placeholder: " ... ",
                                     keyboardType: state.units == .mgdL ? .numberPad : .decimalPad,
                                     numberFormatter: manualGlucoseFormatter,
-                                    initialFocus: true
+                                    initialFocus: true,
+                                    unitsText: state.units.rawValue
                                 )
-                                Text(state.units.rawValue).foregroundStyle(.secondary)
                             }
                         }.listRowBackground(Color.chart)
 

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

@@ -75,7 +75,7 @@ extension UnitsLimitsSettings {
 
                         VStack(alignment: .leading, spacing: 0) {
                             Text("What's NOT limited:")
-                            Text("• Manual boluses you enter yoursef")
+                            Text("• Manual boluses you enter yourself")
                             Text("• Manual temporary basal rates you set yourself")
                         }
                     }

+ 13 - 0
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -20,6 +20,7 @@ extension Home {
         @ObservationIgnored @Injected() var tempTargetStorage: TempTargetsStorage!
         @ObservationIgnored @Injected() var overrideStorage: OverrideStorage!
         @ObservationIgnored @Injected() var bluetoothManager: BluetoothStateManager!
+        @ObservationIgnored @Injected() var iobService: IOBService!
 
         var cgmStateModel: CGMSettings.StateModel {
             CGMSettings.StateModel.shared
@@ -65,6 +66,7 @@ extension Home {
         var manualTempBasal = false
         var isSmoothingEnabled = false
         var maxIOB: Decimal = 0.0
+        var currentIOB: Decimal = 0.0
         var autosensMax: Decimal = 1.2
         var lowGlucose: Decimal = 70
         var highGlucose: Decimal = 180
@@ -215,12 +217,23 @@ extension Home {
                     group.addTask {
                         self.setupTempTargetsRunStored()
                     }
+                    group.addTask {
+                        self.iobService.updateIOB()
+                    }
                 }
             }
         }
 
         // These combine subscribers are only necessary due to the batch inserts of glucose/FPUs which do not trigger a ManagedObjectContext change notification
         private func registerSubscribers() {
+            iobService.iobPublisher
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] _ in
+                    guard let self = self else { return }
+                    self.currentIOB = self.iobService.currentIOB ?? 0
+                }
+                .store(in: &subscriptions)
+
             glucoseStorage.updatePublisher
                 .receive(on: queue)
                 .sink { [weak self] _ in

+ 1 - 1
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -432,7 +432,7 @@ extension Home {
                     Text(
                         (
                             Formatter.decimalFormatterWithTwoFractionDigits
-                                .string(from: (state.enactedAndNonEnactedDeterminations.first?.iob ?? 0) as NSNumber) ?? "0"
+                                .string(from: state.currentIOB as NSNumber) ?? "0"
                         ) +
                             String(localized: " U", comment: "Insulin unit")
                     )

+ 2 - 2
Trio/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift

@@ -27,9 +27,9 @@ extension ManualTempBasal {
                             text: $state.rate,
                             placeholder: "0",
                             numberFormatter: formatter,
-                            initialFocus: true
+                            initialFocus: true,
+                            unitsText: String(localized: "U/hr", comment: "Units text for temporary basal rate")
                         )
-                        Text("U/hr").foregroundColor(.secondary)
                     }
                     Picker(selection: $state.durationIndex, label: Text("Duration")) {
                         ForEach(0 ..< state.durationValues.count) { index in

+ 1 - 1
Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift

@@ -417,7 +417,7 @@ enum DeliveryLimitSubstep: Int, CaseIterable, Identifiable {
 
                 VStack(alignment: .leading, spacing: 0) {
                     Text("What's NOT limited:")
-                    Text("• Manual boluses you enter yoursef")
+                    Text("• Manual boluses you enter yourself")
                     Text("• Manual temporary basal rates you set yourself")
                 }
             }

+ 49 - 18
Trio/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -9,6 +9,8 @@ extension Settings {
         var latestVersion: String?
         var isUpdateAvailable: Bool
         var isBlacklisted: Bool
+        var latestDevVersion: String?
+        var isDevUpdateAvailable: Bool
     }
 
     struct RootView: BaseView {
@@ -27,7 +29,9 @@ extension Settings {
         @State private var versionInfo = VersionInfo(
             latestVersion: nil,
             isUpdateAvailable: false,
-            isBlacklisted: false
+            isBlacklisted: false,
+            latestDevVersion: nil,
+            isDevUpdateAvailable: false
         )
         @State private var closedLoopDisabled = true
 
@@ -40,12 +44,13 @@ extension Settings {
         }
 
         @ViewBuilder var versionInfoView: some View {
-            let latestVersion = versionInfo.latestVersion
-            if let version = latestVersion {
-                let updateColor: Color = versionInfo.isUpdateAvailable ? .orange : .green
-                let versionIconName = versionInfo.isUpdateAvailable ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"
+            VStack(alignment: .leading, spacing: 4) {
+                // Main version info
+                if let version = versionInfo.latestVersion {
+                    let updateColor: Color = versionInfo.isUpdateAvailable ? .orange : .green
+                    let versionIconName = versionInfo
+                        .isUpdateAvailable ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"
 
-                VStack(alignment: .leading, spacing: 4) {
                     HStack {
                         Text("Latest version: \(version)")
                             .font(.footnote)
@@ -62,11 +67,33 @@ extension Settings {
                                 .foregroundColor(.red)
                         }
                     }
+                } else {
+                    Text("Latest version: Fetching...")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+
+                // Show latest dev version on any branch except main
+                let buildDetails = BuildDetails.shared
+                if buildDetails.trioBranch != "main" {
+                    if let devVersion = versionInfo.latestDevVersion {
+                        let devUpdateColor: Color = versionInfo.isDevUpdateAvailable ? .orange : .secondary
+                        let devVersionIconName = versionInfo.isDevUpdateAvailable ? "arrow.up.circle.fill" : "hammer.fill"
+
+                        HStack {
+                            Text("Latest dev: \(devVersion)")
+                                .font(.footnote)
+                                .foregroundColor(devUpdateColor)
+                            Image(systemName: devVersionIconName)
+                                .font(.footnote)
+                                .foregroundColor(devUpdateColor)
+                        }
+                    } else {
+                        Text("Latest dev: Fetching...")
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
                 }
-            } else {
-                Text("Latest version: Fetching...")
-                    .font(.footnote)
-                    .foregroundColor(.secondary)
             }
         }
 
@@ -366,14 +393,18 @@ extension Settings {
             .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
             .screenNavigation(self)
             .onAppear {
-                AppVersionChecker.shared.refreshVersionInfo { _, latestVersion, isNewer, isBlacklisted in
-                    let updateAvailable = isNewer
-                    DispatchQueue.main.async {
-                        versionInfo = VersionInfo(
-                            latestVersion: latestVersion,
-                            isUpdateAvailable: updateAvailable,
-                            isBlacklisted: isBlacklisted
-                        )
+                Task { @MainActor in
+                    let (_, latestVersion, isNewer, isBlacklisted) = await AppVersionChecker.shared.refreshVersionInfo()
+                    versionInfo.latestVersion = latestVersion
+                    versionInfo.isUpdateAvailable = isNewer
+                    versionInfo.isBlacklisted = isBlacklisted
+
+                    // Fetch dev version if not on main branch
+                    let buildDetails = BuildDetails.shared
+                    if buildDetails.trioBranch != "main" {
+                        let (devVersion, isDevNewer) = await AppVersionChecker.shared.checkForNewDevVersion()
+                        versionInfo.latestDevVersion = devVersion
+                        versionInfo.isDevUpdateAvailable = isDevNewer
                     }
                 }
             }

+ 8 - 2
Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift

@@ -121,7 +121,10 @@ struct BolusStatsView: View {
         }
         .onAppear {
             scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
-            updateCalculatedValues()
+            // Delay the initial update to ensure scroll position has been processed
+            DispatchQueue.main.async {
+                updateCalculatedValues()
+            }
         }
         .onChange(of: scrollPosition) {
             updateTimer.scheduleUpdate {
@@ -131,7 +134,10 @@ struct BolusStatsView: View {
         .onChange(of: selectedInterval) {
             Task {
                 scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
-                updateCalculatedValues()
+                // Use async dispatch to ensure scroll position is updated before calculating values
+                await MainActor.run {
+                    updateCalculatedValues()
+                }
             }
         }
     }

+ 11 - 5
Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift

@@ -75,8 +75,11 @@ struct TotalDailyDoseChart: View {
         }
         .onAppear {
             scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
-            updateAverages()
-            updateTotalDoses()
+            // Delay the initial update to ensure scroll position has been processed
+            DispatchQueue.main.async {
+                updateAverages()
+                updateTotalDoses()
+            }
         }
         .onChange(of: scrollPosition) {
             updateTimer.scheduleUpdate {
@@ -89,9 +92,12 @@ struct TotalDailyDoseChart: View {
         .onChange(of: selectedInterval) {
             Task {
                 scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
-                updateAverages()
-                if selectedInterval == .day {
-                    updateTotalDoses()
+                // Use async dispatch to ensure scroll position is updated before calculating averages
+                await MainActor.run {
+                    updateAverages()
+                    if selectedInterval == .day {
+                        updateTotalDoses()
+                    }
                 }
             }
         }

+ 8 - 2
Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift

@@ -100,7 +100,10 @@ struct MealStatsView: View {
         }
         .onAppear {
             scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
-            updateAverages()
+            // Delay the initial update to ensure scroll position has been processed
+            DispatchQueue.main.async {
+                updateAverages()
+            }
         }
         .onChange(of: scrollPosition) {
             updateTimer.scheduleUpdate {
@@ -110,7 +113,10 @@ struct MealStatsView: View {
         .onChange(of: selectedInterval) {
             Task {
                 scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
-                updateAverages()
+                // Use async dispatch to ensure scroll position is updated before calculating averages
+                await MainActor.run {
+                    updateAverages()
+                }
             }
         }
     }

+ 8 - 19
Trio/Sources/Modules/Treatments/View/ForecastChart.swift

@@ -20,20 +20,6 @@ struct ForecastChart: View {
             )) // min is 1.5h -> (1.5*1h = 1.5*(5*12*60))
     }
 
-    private var glucoseFormatter: NumberFormatter {
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-
-        if state.units == .mmolL {
-            formatter.maximumFractionDigits = 1
-            formatter.minimumFractionDigits = 1
-            formatter.roundingMode = .halfUp
-        } else {
-            formatter.maximumFractionDigits = 0
-        }
-        return formatter
-    }
-
     private var selectedGlucose: GlucoseStored? {
         guard let selection = selection else { return nil }
         let range = selection.addingTimeInterval(-150) ... selection.addingTimeInterval(150)
@@ -73,8 +59,11 @@ struct ForecastChart: View {
 
             HStack {
                 Image(systemName: "syringe.fill")
-                Text("\(state.amount.description) U")
+                Text(
+                    "\(Formatter.bolusFormatter.string(from: state.amount as NSNumber) ?? state.amount.description) "
+                ) + Text(String(localized: "U", comment: "Insulin unit"))
             }
+
             .font(.footnote)
             .foregroundStyle(.blue)
             .padding(8)
@@ -185,19 +174,19 @@ struct ForecastChart: View {
                 HStack(spacing: 10) {
                     HStack(spacing: 4) {
                         Image(systemName: "circle.fill").foregroundStyle(Color.insulin)
-                        Text("IOB").foregroundStyle(Color.secondary)
+                        Text(String(localized: "IOB")).foregroundStyle(Color.secondary)
                     }
                     HStack(spacing: 4) {
                         Image(systemName: "circle.fill").foregroundStyle(Color.uam)
-                        Text("UAM").foregroundStyle(Color.secondary)
+                        Text(String(localized: "UAM")).foregroundStyle(Color.secondary)
                     }
                     HStack(spacing: 4) {
                         Image(systemName: "circle.fill").foregroundStyle(Color.zt)
-                        Text("ZT").foregroundStyle(Color.secondary)
+                        Text(String(localized: "ZT")).foregroundStyle(Color.secondary)
                     }
                     HStack(spacing: 4) {
                         Image(systemName: "circle.fill").foregroundStyle(Color.orange)
-                        Text("COB").foregroundStyle(Color.secondary)
+                        Text(String(localized: "COB")).foregroundStyle(Color.secondary)
                     }
                 }.font(.caption2)
             }

+ 6 - 6
Trio/Sources/Modules/Treatments/View/MealPreset/AddMealPresetView.swift

@@ -75,9 +75,9 @@ struct AddMealPresetView: View {
                 text: $presetCarbs,
                 placeholder: "0",
                 keyboardType: .numberPad,
-                numberFormatter: mealFormatter
+                numberFormatter: mealFormatter,
+                unitsText: String(localized: "g", comment: "Units for carbs")
             )
-            Text("g").foregroundColor(.secondary)
         }
     }
 
@@ -89,9 +89,9 @@ struct AddMealPresetView: View {
                 text: $presetProtein,
                 placeholder: "0",
                 keyboardType: .numberPad,
-                numberFormatter: mealFormatter
+                numberFormatter: mealFormatter,
+                unitsText: String(localized: "g", comment: "Units for carbs")
             )
-            Text("g").foregroundColor(.secondary)
         }
         HStack {
             Text("Fat").foregroundColor(.orange)
@@ -100,9 +100,9 @@ struct AddMealPresetView: View {
                 text: $presetFat,
                 placeholder: "0",
                 keyboardType: .numberPad,
-                numberFormatter: mealFormatter
+                numberFormatter: mealFormatter,
+                unitsText: String(localized: "g", comment: "Units for carbs")
             )
-            Text("g").foregroundColor(.secondary)
         }
     }
 

+ 8 - 8
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -93,10 +93,10 @@ extension Treatments {
                         numberFormatter: mealFormatter,
                         showArrows: true,
                         previousTextField: { focusedField = previousField(from: .protein) },
-                        nextTextField: { focusedField = nextField(from: .protein) }
+                        nextTextField: { focusedField = nextField(from: .protein) },
+                        unitsText: String(localized: "g", comment: "Units for carbs")
                     )
                     .focused($focusedField, equals: .protein)
-                    Text("g").foregroundColor(.secondary)
                 }
 
                 Divider().foregroundStyle(.primary).fontWeight(.bold).frame(width: 10)
@@ -110,10 +110,10 @@ extension Treatments {
                         numberFormatter: mealFormatter,
                         showArrows: true,
                         previousTextField: { focusedField = previousField(from: .fat) },
-                        nextTextField: { focusedField = nextField(from: .fat) }
+                        nextTextField: { focusedField = nextField(from: .fat) },
+                        unitsText: String(localized: "g", comment: "Units for carbs")
                     )
                     .focused($focusedField, equals: .fat)
-                    Text("g").foregroundColor(.secondary)
                 }
             }
         }
@@ -129,13 +129,13 @@ extension Treatments {
                     numberFormatter: mealFormatter,
                     showArrows: true,
                     previousTextField: { focusedField = previousField(from: .carbs) },
-                    nextTextField: { focusedField = nextField(from: .carbs) }
+                    nextTextField: { focusedField = nextField(from: .carbs) },
+                    unitsText: String(localized: "g", comment: "Units for carbs")
                 )
                 .focused($focusedField, equals: .carbs)
                 .onChange(of: state.carbs) {
                     handleDebouncedInput()
                 }
-                Text("g").foregroundColor(.secondary)
             }
         }
 
@@ -331,14 +331,14 @@ extension Treatments {
                                     numberFormatter: formatter,
                                     showArrows: true,
                                     previousTextField: { focusedField = previousField(from: .bolus) },
-                                    nextTextField: { focusedField = nextField(from: .bolus) }
+                                    nextTextField: { focusedField = nextField(from: .bolus) },
+                                    unitsText: String(localized: "U", comment: "Units for bolus amount")
                                 ).focused($focusedField, equals: .bolus)
                                     .onChange(of: state.amount) {
                                         Task {
                                             await state.updateForecasts()
                                         }
                                     }
-                                Text(" U").foregroundColor(.secondary)
                             }
 
                             HStack {

+ 307 - 152
Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift

@@ -1,78 +1,88 @@
 import UIKit
 
-/// AppVersionChecker is a singleton responsible for checking the app's version status.
-/// It fetches version data from remote sources (GitHub), caches the results, and notifies the user
-/// if an update is available or if the current version is blacklisted.
+// AppVersionChecker is a singleton responsible for checking the app's version status.
+// It fetches version data from remote sources (GitHub), caches the results, and notifies the user
+// if an update is available or if the current version is blacklisted.
 final class AppVersionChecker {
-    /// Shared singleton instance.
+    // Shared singleton instance.
     static let shared = AppVersionChecker()
 
-    /// Private initializer to enforce the singleton pattern.
+    // Private initializer to enforce the singleton pattern.
     private init() {}
 
     // MARK: - Persisted Properties
 
-    /// Cached app version for which data was last fetched.
+    // Cached app version for which data was last fetched.
     @Persisted(key: "cachedForVersion") private var cachedForVersion: String? = nil
-    /// The latest version fetched from GitHub.
+    // The latest version fetched from GitHub.
     @Persisted(key: "latestVersion") private var persistedLatestVersion: String? = nil
-    /// The date when the latest version was checked.
+    // The date when the latest version was checked.
     @Persisted(key: "latestVersionChecked") private var latestVersionChecked: Date? = .distantPast
-    /// Boolean flag indicating whether the current version is blacklisted.
+    // Boolean flag indicating whether the current version is blacklisted.
     @Persisted(key: "currentVersionBlackListed") private var currentVersionBlackListed: Bool = false
-    /// Timestamp for the last time a blacklist notification was shown.
+    // Timestamp for the last time a blacklist notification was shown.
     @Persisted(key: "lastBlacklistNotificationShown") private var lastBlacklistNotificationShown: Date? = .distantPast
-    /// Timestamp for the last time a version update notification was shown.
+    // Timestamp for the last time a version update notification was shown.
     @Persisted(key: "lastVersionUpdateNotificationShown") private var lastVersionUpdateNotificationShown: Date? = .distantPast
-    /// Timestamp for the last time an expiration notification was shown.
+    // Timestamp for the last time an expiration notification was shown.
     @Persisted(key: "lastExpirationNotificationShown") private var lastExpirationNotificationShown: Date? = .distantPast
 
+    // Dev version properties
+    // Cached app version for which dev data was last fetched.
+    @Persisted(key: "cachedForDevVersion") private var cachedForDevVersion: String? = nil
+    // The latest dev version fetched from GitHub.
+    @Persisted(key: "latestDevVersion") private var persistedLatestDevVersion: String? = nil
+    // The date when the latest dev version was checked.
+    @Persisted(key: "latestDevVersionChecked") private var latestDevVersionChecked: Date? = .distantPast
+
     // MARK: - Nested Types
 
-    /// GitHubDataType defines the type of data to fetch from GitHub for version checking.
+    // GitHubDataType defines the type of data to fetch from GitHub for version checking.
     private enum GitHubDataType {
-        /// The configuration file containing version information.
+        // The configuration file containing version information.
         case versionConfig
-        /// The JSON file listing blacklisted versions.
+        // The configuration file containing dev version information.
+        case devVersionConfig
+        // The JSON file listing blacklisted versions.
         case blacklistedVersions
 
-        /// Returns the URL string associated with the data type.
+        // Returns the URL string associated with the data type.
         var url: String {
             switch self {
             case .versionConfig:
                 return "https://raw.githubusercontent.com/nightscout/Trio/refs/heads/main/Config.xcconfig"
+            case .devVersionConfig:
+                return "https://raw.githubusercontent.com/nightscout/Trio/refs/heads/dev/Config.xcconfig"
             case .blacklistedVersions:
                 return "https://raw.githubusercontent.com/nightscout/Trio/refs/heads/main/blacklisted-versions.json"
             }
         }
     }
 
-    /// Model for decoding the blacklist JSON from GitHub.
+    // Model for decoding the blacklist JSON from GitHub.
     private struct Blacklist: Decodable {
-        /// Array of blacklisted version entries.
+        // Array of blacklisted version entries.
         let blacklistedVersions: [VersionEntry]
     }
 
-    /// Model representing a single version entry in the blacklist.
+    // Model representing a single version entry in the blacklist.
     private struct VersionEntry: Decodable {
-        /// The version string that is blacklisted.
+        // The version string that is blacklisted.
         let version: String
     }
 
     // MARK: - Public Methods
 
-    /**
-     Checks for a new or blacklisted version and presents an alert if necessary.
-
-     This method determines whether there is an update or if the current version is blacklisted.
-     Depending on the result, it displays an alert on the given view controller, ensuring that alerts
-     are not shown too frequently (24 hours for blacklist and 2 weeks for update notifications).
-
-     - Parameter viewController: The UIViewController on which to present any alerts.
-     */
+    // Checks for a new or blacklisted version and presents an alert if necessary.
+    //
+    // This method determines whether there is an update or if the current version is blacklisted.
+    // Depending on the result, it displays an alert on the given view controller, ensuring that alerts
+    // are not shown too frequently (24 hours for blacklist and 2 weeks for update notifications).
+    //
+    // - Parameter viewController: The UIViewController on which to present any alerts.
     func checkAndNotifyVersionStatus(in viewController: UIViewController) {
-        checkForNewVersion { [weak viewController] latestVersion, isNewer, isBlacklisted in
-            guard let vc = viewController else { return }
+        Task { @MainActor in
+            let (latestVersion, isNewer, isBlacklisted) = await checkForNewVersion()
             let now = Date()
 
             // If the current version is blacklisted, show a critical update alert if not shown in the last 24 hours.
@@ -80,7 +90,7 @@ final class AppVersionChecker {
                 let lastShown = self.lastBlacklistNotificationShown ?? .distantPast
                 if now.timeIntervalSince(lastShown) > 86400 { // 24 hours
                     self.showAlert(
-                        on: vc,
+                        on: viewController,
                         title: String(localized: "Update Required", comment: "Title for critical update alert"),
                         message: String(
                             localized: "The current version has a critical issue and should be updated as soon as possible.",
@@ -97,7 +107,7 @@ final class AppVersionChecker {
                 if now.timeIntervalSince(lastShown) > 1_209_600 { // 2 weeks
                     let versionText = latestVersion ?? String(localized: "Unknown", comment: "Fallback text for unknown version")
                     self.showAlert(
-                        on: vc,
+                        on: viewController,
                         title: String(localized: "Update Available", comment: "Title for update available alert"),
                         message: String(
                             localized: "A new version (\(versionText)) is available. It is recommended to update.",
@@ -110,46 +120,163 @@ final class AppVersionChecker {
         }
     }
 
-    /**
-     Refreshes the version information and returns the current state.
-
-     This method triggers a version check (using cached values if valid or fetching fresh data)
-     and then returns the current app version along with the latest version info, a flag indicating
-     whether the latest version is newer, and a flag indicating if the current version is blacklisted.
-
-     - Parameter completion: A closure that receives the following parameters:
-     - currentVersion: The current app version.
-     - latestVersion: The latest version fetched from GitHub (if available).
-     - isNewer: `true` if the fetched version is newer than the current version.
-     - isBlacklisted: `true` if the current version is blacklisted.
-     */
+    // Refreshes the version information and returns the current state (completion handler version).
+    //
+    // This method triggers a version check (using cached values if valid or fetching fresh data)
+    // and then returns the current app version along with the latest version info, a flag indicating
+    // whether the latest version is newer, and a flag indicating if the current version is blacklisted.
+    //
+    // - Parameter completion: A closure that receives the following parameters:
+    // - currentVersion: The current app version.
+    // - latestVersion: The latest version fetched from GitHub (if available).
+    // - isNewer: `true` if the fetched version is newer than the current version.
+    // - isBlacklisted: `true` if the current version is blacklisted.
     func refreshVersionInfo(completion: @escaping (
         String,
         String?,
         Bool,
         Bool
     ) -> Void) {
+        Task {
+            let result = await refreshVersionInfo()
+            completion(result.currentVersion, result.latestVersion, result.isNewer, result.isBlacklisted)
+        }
+    }
+
+    // Refreshes the version information and returns the current state (async version).
+    //
+    // This method triggers a version check (using cached values if valid or fetching fresh data)
+    // and then returns the current app version along with the latest version info, a flag indicating
+    // whether the latest version is newer, and a flag indicating if the current version is blacklisted.
+    //
+    // - Returns: A tuple containing:
+    // - currentVersion: The current app version.
+    // - latestVersion: The latest version fetched from GitHub (if available).
+    // - isNewer: `true` if the fetched version is newer than the current version.
+    // - isBlacklisted: `true` if the current version is blacklisted.
+    func refreshVersionInfo() async -> (currentVersion: String, latestVersion: String?, isNewer: Bool, isBlacklisted: Bool) {
         let currentVersion = version()
-        checkForNewVersion { latestVersion, isNewer, isBlacklisted in
-            completion(currentVersion, latestVersion, isNewer, isBlacklisted)
+        let (latestVersion, isNewer, isBlacklisted) = await checkForNewVersion()
+        return (currentVersion, latestVersion, isNewer, isBlacklisted)
+    }
+
+    // Checks for the latest dev version with caching and comparison (completion handler version).
+    //
+    // This method attempts to use cached dev version data if it is less than 24 hours old and
+    // corresponds to the current app version. If the cache is invalid or outdated,
+    // it fetches fresh data from GitHub.
+    //
+    // - Parameter completion: A closure that receives:
+    // - latestDevVersion: The latest dev version string (if available).
+    // - isNewer: `true` if the fetched dev version is newer than the current version.
+    func checkForNewDevVersion(completion: @escaping (String?, Bool) -> Void) {
+        Task {
+            let result = await checkForNewDevVersion()
+            completion(result.0, result.1)
         }
     }
 
-    // MARK: - Core Version Checking Logic
+    // Checks for the latest dev version with caching and comparison (async version).
+    //
+    // This method attempts to use cached dev version data if it is less than 24 hours old and
+    // corresponds to the current app version. If the cache is invalid or outdated,
+    // it fetches fresh data from GitHub.
+    //
+    // - Returns: A tuple containing:
+    // - latestDevVersion: The latest dev version string (if available).
+    // - isNewer: `true` if the fetched dev version is newer than the current version.
+    func checkForNewDevVersion() async -> (String?, Bool) {
+        // For dev version, we need to compare against the current dev version, not the main version
+        let currentDevVersion = Bundle.main.object(forInfoDictionaryKey: "AppDevVersion") as? String ?? version()
+        let now = Date()
+
+        // Retrieve cached values
+        let lastChecked = latestDevVersionChecked ?? .distantPast
+        let cachedVersion = cachedForDevVersion
+        let persistedLatestDev = persistedLatestDevVersion
+
+        // Use cached data if it is valid (less than 24 hours old) and matches the current version
+        if let cachedVersion = cachedVersion,
+           cachedVersion == currentDevVersion,
+           now.timeIntervalSince(lastChecked) < 24 * 3600,
+           let persistedLatestDev = persistedLatestDev
+        {
+            let isNewer = isVersion(persistedLatestDev, newerThan: currentDevVersion)
+            return (persistedLatestDev, isNewer)
+        }
+
+        // Otherwise, fetch fresh data from GitHub and update the cache
+        return await fetchDevVersionAndUpdateCache(currentVersion: currentDevVersion)
+    }
+
+    // Fetches dev version data from GitHub, updates persisted values, and returns the result.
+    //
+    // - Parameters:
+    // - currentVersion: The current app version.
+    // - Returns: A tuple containing:
+    // - latestDevVersion: The latest dev version string from GitHub (if available).
+    // - isNewer: `true` if the fetched dev version is newer than the current version.
+    private func fetchDevVersionAndUpdateCache(currentVersion: String) async -> (String?, Bool) {
+        let versionData = await fetchData(for: .devVersionConfig)
+
+        // Parse the dev version from the fetched configuration data
+        let configContents = versionData.flatMap { String(data: $0, encoding: .utf8) }
+        let fetchedDevVersion = configContents.flatMap { self.parseDevVersionFromConfig(contents: $0) }
+
+        #if DEBUG
+            print("AppVersionChecker.fetchDevVersion: Current dev version: \(currentVersion)")
+            print("AppVersionChecker.fetchDevVersion: Fetched dev version: \(fetchedDevVersion ?? "nil")")
+            if let contents = configContents {
+                let lines = contents.split(separator: "\n")
+                for line in lines where line.contains("VERSION") {
+                    print("AppVersionChecker.fetchDevVersion: Config line: \(line)")
+                }
+            }
+        #endif
 
-    /**
-     Checks whether there is a new or blacklisted version.
+        // Determine if the fetched dev version is newer than the current version
+        let isNewer = fetchedDevVersion.map {
+            self.isVersion($0, newerThan: currentVersion)
+        } ?? false
 
-     This method attempts to use cached version data if it is less than 24 hours old and
-     corresponds to the current app version. If the cache is invalid or outdated,
-     it fetches fresh data from GitHub.
+        // Update persisted cache
+        persistedLatestDevVersion = fetchedDevVersion
+        latestDevVersionChecked = Date()
+        cachedForDevVersion = currentVersion
 
-     - Parameter completion: A closure that receives:
-     - latestVersion: The latest version string (if available).
-     - isNewer: `true` if the fetched version is newer than the current version.
-     - isBlacklisted: `true` if the current version is blacklisted.
-     */
+        return (fetchedDevVersion, isNewer)
+    }
+
+    // MARK: - Core Version Checking Logic
+
+    // Checks whether there is a new or blacklisted version (completion handler version).
+    //
+    // This method attempts to use cached version data if it is less than 24 hours old and
+    // corresponds to the current app version. If the cache is invalid or outdated,
+    // it fetches fresh data from GitHub.
+    //
+    // - Parameter completion: A closure that receives:
+    // - latestVersion: The latest version string (if available).
+    // - isNewer: `true` if the fetched version is newer than the current version.
+    // - isBlacklisted: `true` if the current version is blacklisted.
     private func checkForNewVersion(completion: @escaping (String?, Bool, Bool) -> Void) {
+        Task {
+            let result = await checkForNewVersion()
+            completion(result.0, result.1, result.2)
+        }
+    }
+
+    // Checks whether there is a new or blacklisted version (async version).
+    //
+    // This method attempts to use cached version data if it is less than 24 hours old and
+    // corresponds to the current app version. If the cache is invalid or outdated,
+    // it fetches fresh data from GitHub.
+    //
+    // - Returns: A tuple containing:
+    // - latestVersion: The latest version string (if available).
+    // - isNewer: `true` if the fetched version is newer than the current version.
+    // - isBlacklisted: `true` if the current version is blacklisted.
+    private func checkForNewVersion() async -> (String?, Bool, Bool) {
         let currentVersion = version()
         let now = Date()
 
@@ -172,73 +299,87 @@ final class AppVersionChecker {
            let persistedLatest = persistedLatest
         {
             let isNewer = isVersion(persistedLatest, newerThan: currentVersion)
-            completion(persistedLatest, isNewer, isBlacklistedCached)
-            return
+            return (persistedLatest, isNewer, isBlacklistedCached)
         }
 
         // Otherwise, fetch fresh data from GitHub and update the cache.
-        fetchDataAndUpdateCache(currentVersion: currentVersion, completion: completion)
+        return await fetchDataAndUpdateCache(currentVersion: currentVersion)
     }
 
-    /**
-     Fetches version and blacklist data from GitHub, updates persisted values, and invokes the completion handler.
-
-     This method performs two sequential network requests: first for the version configuration and then for the
-     blacklisted versions. After parsing the fetched data and comparing version values, it updates the cache and calls
-     the completion handler with the results.
-
-     - Parameters:
-     - currentVersion: The current app version.
-     - completion: A closure that receives:
-     - latestVersion: The latest version string from GitHub (if available).
-     - isNewer: `true` if the fetched version is newer than the current version.
-     - isBlacklisted: `true` if the current version is blacklisted.
-     */
-    private func fetchDataAndUpdateCache(currentVersion: String, completion: @escaping (String?, Bool, Bool) -> Void) {
-        fetchData(for: .versionConfig) { versionData in
-            self.fetchData(for: .blacklistedVersions) { blacklistData in
-                DispatchQueue.main.async {
-                    // Parse the version from the fetched configuration data.
-                    let fetchedVersion = versionData
-                        .flatMap { String(data: $0, encoding: .utf8) }
-                        .flatMap { self.parseVersionFromConfig(contents: $0) }
-
-                    // Determine if the fetched version is newer than the current version.
-                    let isNewer = fetchedVersion.map {
-                        self.isVersion($0, newerThan: currentVersion)
-                    } ?? false
-
-                    // Determine if the current version is blacklisted.
-                    let isBlacklisted = (try? blacklistData.flatMap {
-                        try JSONDecoder().decode(Blacklist.self, from: $0)
-                    })?.blacklistedVersions
-                        .map(\.version)
-                        .contains(currentVersion) ?? false
-
-                    // Update persisted cache.
-                    self.persistedLatestVersion = fetchedVersion
-                    self.latestVersionChecked = Date()
-                    self.currentVersionBlackListed = isBlacklisted
-                    self.cachedForVersion = currentVersion
-
-                    completion(fetchedVersion, isNewer, isBlacklisted)
-                }
-            }
-        }
+    // Fetches version and blacklist data from GitHub, updates persisted values, and returns the result.
+    //
+    // This method performs two parallel network requests: one for the version configuration and one for the
+    // blacklisted versions. After parsing the fetched data and comparing version values, it updates the cache and
+    // returns the results.
+    //
+    // - Parameters:
+    // - currentVersion: The current app version.
+    // - Returns: A tuple containing:
+    // - latestVersion: The latest version string from GitHub (if available).
+    // - isNewer: `true` if the fetched version is newer than the current version.
+    // - isBlacklisted: `true` if the current version is blacklisted.
+    private func fetchDataAndUpdateCache(currentVersion: String) async -> (String?, Bool, Bool) {
+        // Fetch both data types in parallel
+        async let versionData = fetchData(for: .versionConfig)
+        async let blacklistData = fetchData(for: .blacklistedVersions)
+
+        let (versionDataResult, blacklistDataResult) = await (versionData, blacklistData)
+
+        // Parse the version from the fetched configuration data.
+        let fetchedVersion = versionDataResult
+            .flatMap { String(data: $0, encoding: .utf8) }
+            .flatMap { self.parseVersionFromConfig(contents: $0) }
+
+        // Determine if the fetched version is newer than the current version.
+        let isNewer = fetchedVersion.map {
+            self.isVersion($0, newerThan: currentVersion)
+        } ?? false
+
+        // Determine if the current version is blacklisted.
+        let isBlacklisted = (try? blacklistDataResult.flatMap {
+            try JSONDecoder().decode(Blacklist.self, from: $0)
+        })?.blacklistedVersions
+            .map(\.version)
+            .contains(currentVersion) ?? false
+
+        // Update persisted cache.
+        persistedLatestVersion = fetchedVersion
+        latestVersionChecked = Date()
+        currentVersionBlackListed = isBlacklisted
+        cachedForVersion = currentVersion
+
+        return (fetchedVersion, isNewer, isBlacklisted)
     }
 
     // MARK: - Data Fetching Helper
 
-    /**
-     Fetches data from GitHub for a specified data type.
+    // Fetches data from GitHub for a specified data type.
+    //
+    // This helper method builds a URL from the provided GitHubDataType and executes a network request.
+    // If the request is successful and returns valid data (HTTP status 200), the data is returned.
+    //
+    // - Parameters:
+    // - dataType: The type of GitHub data to fetch (version configuration or blacklisted versions).
+    // - Returns: The fetched data as an optional `Data` object.
+    private func fetchData(for dataType: GitHubDataType) async -> Data? {
+        guard let url = URL(string: dataType.url) else {
+            return nil
+        }
 
-     This helper method builds a URL from the provided GitHubDataType and executes a network request.
-     If the request is successful and returns valid data (HTTP status 200), the data is passed to the completion handler.
+        do {
+            let (data, response) = try await URLSession.shared.data(from: url)
+            guard let httpResponse = response as? HTTPURLResponse,
+                  httpResponse.statusCode == 200
+            else {
+                return nil
+            }
+            return data
+        } catch {
+            return nil
+        }
+    }
 
-     - Parameters:
-     - dataType: The type of GitHub data to fetch (version configuration or blacklisted versions).
-     - completion: A closure that receives the fetched data as an optional `Data` object.
-     */
+    // Legacy completion handler version for existing code
     private func fetchData(for dataType: GitHubDataType, completion: @escaping (Data?) -> Void) {
         guard let url = URL(string: dataType.url) else {
             completion(nil)
@@ -259,19 +400,17 @@ final class AppVersionChecker {
 
     // MARK: - Helpers
 
-    /**
-     Parses the version string from the contents of a configuration file.
-
-     The method scans each line of the provided content for an occurrence of "APP_VERSION" and then
-     extracts the version number following the "=" delimiter.
-
-     - Parameter contents: A string containing the contents of the configuration file.
-     - Returns: The extracted version string if found; otherwise, `nil`.
-     */
+    // Parses the version string from the contents of a configuration file.
+    //
+    // The method scans each line of the provided content for an occurrence of "APP_VERSION" and then
+    // extracts the version number following the "=" delimiter.
+    //
+    // - Parameter contents: A string containing the contents of the configuration file.
+    // - Returns: The extracted version string if found; otherwise, `nil`.
     private func parseVersionFromConfig(contents: String) -> String? {
         let lines = contents.split(separator: "\n")
         for line in lines {
-            if line.contains("APP_VERSION") {
+            if line.contains("APP_VERSION"), !line.contains("DEV") {
                 let components = line.split(separator: "=").map {
                     $0.trimmingCharacters(in: .whitespacesAndNewlines)
                 }
@@ -283,18 +422,38 @@ final class AppVersionChecker {
         return nil
     }
 
-    /**
-     Compares two version strings to determine if the fetched version is newer than the current version.
-
-     The version strings are split into numeric components and compared sequentially.
-     If any component of the fetched version is greater than its counterpart in the current version,
-     the function returns `true`; if lower, it returns `false`.
+    // Parses the dev version string from the contents of a configuration file.
+    //
+    // The method scans each line of the provided content for an occurrence of "APP_DEV_VERSION" and then
+    // extracts the version number following the "=" delimiter.
+    //
+    // - Parameter contents: A string containing the contents of the configuration file.
+    // - Returns: The extracted dev version string if found; otherwise, `nil`.
+    private func parseDevVersionFromConfig(contents: String) -> String? {
+        let lines = contents.split(separator: "\n")
+        for line in lines {
+            if line.contains("APP_DEV_VERSION") {
+                let components = line.split(separator: "=").map {
+                    $0.trimmingCharacters(in: .whitespacesAndNewlines)
+                }
+                if components.count > 1 {
+                    return components[1]
+                }
+            }
+        }
+        return nil
+    }
 
-     - Parameters:
-     - fetchedVersion: The version string obtained from GitHub.
-     - currentVersion: The current app version.
-     - Returns: `true` if the fetched version is newer than the current version; otherwise, `false`.
-     */
+    // Compares two version strings to determine if the fetched version is newer than the current version.
+    //
+    // The version strings are split into numeric components and compared sequentially.
+    // If any component of the fetched version is greater than its counterpart in the current version,
+    // the function returns `true`; if lower, it returns `false`.
+    //
+    // - Parameters:
+    // - fetchedVersion: The version string obtained from GitHub.
+    // - currentVersion: The current app version.
+    // - Returns: `true` if the fetched version is newer than the current version; otherwise, `false`.
     private func isVersion(_ fetchedVersion: String, newerThan currentVersion: String) -> Bool {
         let fetchedComponents = fetchedVersion.split(separator: ".").map { Int($0) ?? 0 }
         let currentComponents = currentVersion.split(separator: ".").map { Int($0) ?? 0 }
@@ -312,26 +471,22 @@ final class AppVersionChecker {
         return false
     }
 
-    /**
-     Retrieves the current app version from the main bundle.
-
-     - Returns: The current app version as defined in the app's Info.plist under "CFBundleShortVersionString",
-     or `"Unknown"` if not available.
-     */
+    // Retrieves the current app version from the main bundle.
+    //
+    // - Returns: The current app version as defined in the app's Info.plist under "CFBundleShortVersionString",
+    // or `"Unknown"` if not available.
     private func version() -> String {
         Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
     }
 
-    /**
-     Presents an alert on the specified view controller with a given title and message.
-
-     The alert is dispatched to the main thread to ensure UI updates occur correctly.
-
-     - Parameters:
-     - viewController: The UIViewController on which the alert should be presented.
-     - title: The title text for the alert.
-     - message: The body message of the alert.
-     */
+    // Presents an alert on the specified view controller with a given title and message.
+    //
+    // The alert is dispatched to the main thread to ensure UI updates occur correctly.
+    //
+    // - Parameters:
+    // - viewController: The UIViewController on which the alert should be presented.
+    // - title: The title text for the alert.
+    // - message: The body message of the alert.
     private func showAlert(on viewController: UIViewController, title: String, message: String) {
         DispatchQueue.main.async {
             let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)

+ 2 - 1
Trio/Sources/Services/Calendar/CalendarManager.swift

@@ -18,6 +18,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var storage: FileStorage!
+    @Injected() private var iobService: IOBService!
 
     // Queue for handling Core Data change notifications
     private let queue = DispatchQueue(label: "BaseCalendarManager.queue", qos: .background)
@@ -273,7 +274,7 @@ final class BaseCalendarManager: CalendarManager, Injectable {
             let deltaValue = settingsManager.settings.units == .mmolL ? delta.asMmolL : delta
             let deltaText = deltaFormatter.string(from: deltaValue as NSNumber) ?? "--"
 
-            let iobText = iobFormatter.string(from: (determinationObject.iob ?? 0) as NSNumber) ?? ""
+            let iobText = iobFormatter.string(from: (iobService.currentIOB ?? 0) as NSNumber) ?? ""
             let cobText = cobFormatter.string(from: determinationObject.cob as NSNumber) ?? ""
 
             var glucoseDisplayText = displayEmojis ? glucoseIcon + " " : ""

+ 13 - 1
Trio/Sources/Services/ContactImage/ContactImageManager.swift

@@ -23,6 +23,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
     @Injected() private var contactImageStorage: ContactImageStorage!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var fileStorage: FileStorage!
+    @Injected() private var iobService: IOBService!
 
     private let contactStore = CNContactStore()
 
@@ -71,6 +72,17 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
             }
             .store(in: &subscriptions)
 
+        iobService.iobPublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
+                Task {
+                    await self.updateContactImageState()
+                    await self.updateContactImages()
+                }
+            }
+            .store(in: &subscriptions)
+
         registerHandlers()
     }
 
@@ -207,7 +219,7 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
 
             state.lastLoopDate = lastDetermination?.timestamp
 
-            let iobValue = lastDetermination?.iob as? Decimal ?? 0.0
+            let iobValue = iobService.currentIOB ?? 0.0
             state.iob = iobValue
             state.iobText = Formatter.decimalFormatterWithOneFractionDigit.string(from: iobValue as NSNumber)
 

+ 115 - 0
Trio/Sources/Services/IOB/IOBService.swift

@@ -0,0 +1,115 @@
+import Combine
+import CoreData
+import Foundation
+import Swinject
+
+protocol IOBService {
+    var iobPublisher: AnyPublisher<Decimal?, Never> { get }
+    var currentIOB: Decimal? { get }
+    func updateIOB()
+}
+
+/// The single source of truth for current IoB data
+///
+/// The main idea behind this class is that we want one single place to lookup IoB values that is separate
+/// from determinations. Behind the scenes it uses determinations or IoB results stored in the file system
+/// but these are implementation details that we can change with time.
+///
+// TODO: Calculate IoB using APSManager after enough time has elapsed from the last file or determination data
+final class BaseIOBService: IOBService, Injectable {
+    @Injected() private var fileStorage: FileStorage!
+    @Injected() private var determinationStorage: DeterminationStorage!
+    @Injected() private var apsManager: APSManager!
+
+    private let iobSubject = CurrentValueSubject<Decimal?, Never>(nil)
+    var iobPublisher: AnyPublisher<Decimal?, Never> {
+        iobSubject.eraseToAnyPublisher()
+    }
+
+    // Query the current IOB syncrhonously
+    var currentIOB: Decimal? {
+        lookupIOB()
+    }
+
+    private var subscriptions = Set<AnyCancellable>()
+    private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
+    private let queue = DispatchQueue(label: "BaseIOBService.queue", qos: .background)
+    private let context = CoreDataStack.shared.newTaskContext()
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        coreDataPublisher =
+            changedObjectsOnManagedObjectContextDidSavePublisher()
+                .receive(on: queue)
+                .share()
+                .eraseToAnyPublisher()
+        subscribe()
+    }
+
+    private func subscribe() {
+        // Trigger update when a new determination is available
+        coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
+            self?.updateIOB()
+        }.store(in: &subscriptions)
+
+        // Trigger update when the iob file is updated
+        apsManager.iobFileDidUpdate
+            .sink { [weak self] _ in
+                self?.updateIOB()
+            }
+            .store(in: &subscriptions)
+    }
+
+    // Fetches the IoB and timestamp from the most recent determination
+    private func fetchLatestDeterminationIOB() -> (iob: Decimal?, date: Date?) {
+        var iob: Decimal?
+        var date: Date?
+        context.performAndWait {
+            let request = OrefDetermination.fetchRequest() as NSFetchRequest<OrefDetermination>
+            request.sortDescriptors = [NSSortDescriptor(key: "deliverAt", ascending: false)]
+            request.fetchLimit = 1
+            if let determination = try? context.fetch(request).first {
+                iob = determination.iob as? Decimal
+                date = determination.deliverAt
+            }
+        }
+        return (iob, date)
+    }
+
+    // Lookup IOB data from the file system and determinations core data, use the most
+    // recent value
+    func lookupIOB() -> Decimal? {
+        let iobFromFile = fileStorage.retrieve(OpenAPS.Monitor.iob, as: [IOBEntry].self)
+        let iobFromFileValue = iobFromFile?.first?.iob
+        let iobFromFileDate = iobFromFile?.first?.time
+
+        let (iobFromDetermination, iobFromDeterminationDate) = fetchLatestDeterminationIOB()
+
+        var mostRecentIOB: Decimal?
+
+        if let iobFromFileValue = iobFromFileValue, let iobFromFileDate = iobFromFileDate {
+            if let iobFromDetermination = iobFromDetermination, let iobFromDeterminationDate = iobFromDeterminationDate {
+                if iobFromFileDate > iobFromDeterminationDate {
+                    mostRecentIOB = iobFromFileValue
+                } else {
+                    mostRecentIOB = iobFromDetermination
+                }
+            } else {
+                mostRecentIOB = iobFromFileValue
+            }
+        } else {
+            mostRecentIOB = iobFromDetermination
+        }
+
+        return mostRecentIOB
+    }
+
+    func updateIOB() {
+        Task {
+            let mostRecentIOB = lookupIOB()
+            if iobSubject.value != mostRecentIOB {
+                iobSubject.send(mostRecentIOB)
+            }
+        }
+    }
+}

+ 1 - 2
Trio/Sources/Services/LiveActivity/Data/DataManager.swift

@@ -34,7 +34,7 @@ extension LiveActivityManager {
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1,
-            propertiesToFetch: ["iob", "cob", "currentTarget", "deliverAt"]
+            propertiesToFetch: ["cob", "currentTarget", "deliverAt"]
         )
 
         let tddResults = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -60,7 +60,6 @@ extension LiveActivityManager {
 
             return DeterminationData(
                 cob: (determination["cob"] as? Int) ?? 0,
-                iob: (determination["iob"] as? NSDecimalNumber)?.decimalValue ?? 0,
                 tdd: tddValue,
                 target: (determination["currentTarget"] as? NSDecimalNumber)?.decimalValue ?? 0,
                 date: determination["deliverAt"] as? Date ?? nil

+ 0 - 1
Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift

@@ -2,7 +2,6 @@ import Foundation
 
 struct DeterminationData {
     let cob: Int
-    let iob: Decimal
     let tdd: Decimal
     let target: Decimal
     let date: Date?

+ 2 - 1
Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -62,6 +62,7 @@ extension LiveActivityAttributes.ContentState {
         chart: [GlucoseData],
         settings: TrioSettings,
         determination: DeterminationData?,
+        iob: Decimal?,
         override: OverrideData?,
         widgetItems: [LiveActivityAttributes.LiveActivityItem]?
     ) {
@@ -108,7 +109,7 @@ extension LiveActivityAttributes.ContentState {
                 chartDate: chartDate,
                 rotationDegrees: rotationDegrees,
                 cob: Decimal(determination?.cob ?? 0),
-                iob: determination?.iob ?? 0 as Decimal,
+                iob: iob ?? 0 as Decimal,
                 tdd: determination?.tdd ?? 0 as Decimal,
                 isOverrideActive: override?.isActive ?? false,
                 overrideName: override?.overrideName ?? "Override",

+ 10 - 0
Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

@@ -29,6 +29,8 @@ import UIKit
 final class LiveActivityData: ObservableObject {
     /// Determination data used to update live activity state.
     @Published var determination: DeterminationData?
+    /// The most recent IoB data
+    @Published var iob: Decimal?
     /// Array of glucose readings fetched from persistent storage.
     @Published var glucoseFromPersistence: [GlucoseData]?
     /// The current override data (if any).
@@ -51,6 +53,7 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var storage: FileStorage!
     @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() private var iobService: IOBService!
 
     private let activityAuthorizationInfo = ActivityAuthorizationInfo()
     /// Indicates whether system live activities are enabled.
@@ -147,6 +150,12 @@ final class LiveActivityManager: Injectable, ObservableObject, SettingsObserver
             .sink { [weak self] _ in
                 Task { await self?.loadDetermination() }
             }.store(in: &subscriptions)
+
+        iobService.iobPublisher
+            .debounce(for: .seconds(2), scheduler: DispatchQueue.global(qos: .utility))
+            .sink { [weak self] _ in
+                self?.data.iob = self?.iobService.currentIOB
+            }.store(in: &subscriptions)
     }
 
     /// Fetches and maps new determination data and updates the live activity content state.
@@ -374,6 +383,7 @@ extension LiveActivityManager {
             chart: glucose,
             settings: settings,
             determination: determination,
+            iob: data.iob,
             override: data.override,
             widgetItems: data.widgetItems
         )

+ 3 - 25
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift

@@ -18,7 +18,9 @@ extension TrioRemoteControl {
         }
 
         let maxIOB = settings.preferences.maxIOB
-        let currentIOB = try await fetchCurrentIOB()
+        guard let currentIOB = iobService.currentIOB else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
+        }
         if (currentIOB + bolusAmount) > maxIOB {
             await logError(
                 "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units.",
@@ -56,30 +58,6 @@ extension TrioRemoteControl {
         )
     }
 
-    private func fetchCurrentIOB() async throws -> Decimal {
-        let predicate = NSPredicate.predicateFor30MinAgoForDetermination
-
-        let determinations = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: OrefDetermination.self,
-            onContext: pumpHistoryFetchContext,
-            predicate: predicate,
-            key: "timestamp",
-            ascending: false,
-            fetchLimit: 1,
-            propertiesToFetch: ["iob"]
-        )
-
-        guard let fetchedResults = determinations as? [[String: Any]],
-              let firstResult = fetchedResults.first,
-              let iob = firstResult["iob"] as? Decimal
-        else {
-            await logError("Failed to fetch current IOB.")
-            throw CoreDataError.fetchError(function: #function, file: #file)
-        }
-
-        return iob
-    }
-
     private func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
         let predicate = NSPredicate(
             format: "type == %@ AND timestamp > %@",

+ 1 - 0
Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift

@@ -10,6 +10,7 @@ class TrioRemoteControl: Injectable {
     @Injected() internal var nightscoutManager: NightscoutManager!
     @Injected() internal var overrideStorage: OverrideStorage!
     @Injected() internal var settings: SettingsManager!
+    @Injected() internal var iobService: IOBService!
 
     private let timeWindow: TimeInterval = 600 // Defines how old messages that are accepted, 10 minutes
 

+ 45 - 4
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -24,6 +24,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     @Injected() private var overrideStorage: OverrideStorage!
     @Injected() private var tempTargetStorage: TempTargetsStorage!
     @Injected() private var bolusCalculationManager: BolusCalculationManager!
+    @Injected() private var iobService: IOBService!
 
     private var units: GlucoseUnits = .mgdL
     private var glucoseColorScheme: GlucoseColorScheme = .staticColor
@@ -69,6 +70,20 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             .receive(on: DispatchQueue.global(qos: .background))
             .sink { [weak self] _ in
                 guard let self = self else { return }
+                // Skip if no watch is paired or app not installed
+                guard let session = self.session, session.isPaired, session.isReachable,
+                      session.isWatchAppInstalled else { return }
+                Task {
+                    let state = await self.setupWatchState()
+                    await self.sendDataToWatch(state)
+                }
+            }
+            .store(in: &subscriptions)
+
+        iobService.iobPublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
                 Task {
                     let state = await self.setupWatchState()
                     await self.sendDataToWatch(state)
@@ -82,6 +97,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     private func registerHandlers() {
         coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
             guard let self = self else { return }
+            // Skip if no watch is paired or app not installed
+            guard let session = self.session, session.isPaired, session.isReachable, session.isWatchAppInstalled else { return }
             Task {
                 let state = await self.setupWatchState()
                 await self.sendDataToWatch(state)
@@ -91,6 +108,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
         // Due to the Batch insert this only is used for observing Deletion of Glucose entries
         coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
             guard let self = self else { return }
+            // Skip if no watch is paired or app not installed
+            guard let session = self.session, session.isPaired, session.isReachable, session.isWatchAppInstalled else { return }
             Task {
                 let state = await self.setupWatchState()
                 await self.sendDataToWatch(state)
@@ -106,6 +125,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
         coreDataPublisher?.filteredByEntityName("OverrideStored").sink { [weak self] _ in
             guard let self = self else { return }
+            // Skip if no watch is paired or app not installed
+            guard let session = self.session, session.isPaired, session.isReachable, session.isWatchAppInstalled else { return }
             Task {
                 let state = await self.setupWatchState()
                 await self.sendDataToWatch(state)
@@ -114,6 +135,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 
         coreDataPublisher?.filteredByEntityName("TempTargetStored").sink { [weak self] _ in
             guard let self = self else { return }
+            // Skip if no watch is paired or app not installed
+            guard let session = self.session, session.isPaired, session.isReachable, session.isWatchAppInstalled else { return }
             Task {
                 let state = await self.setupWatchState()
                 await self.sendDataToWatch(state)
@@ -148,6 +171,17 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     /// Prepares the current state data to be sent to the Watch
     /// - Returns: WatchState containing current glucose readings and trends and determination infos for displaying cob and iob in the view
     func setupWatchState() async -> WatchState {
+        // Check if a watch is paired and reachable before doing expensive calculations
+        guard let session = session, session.isPaired, session.isReachable, session.isWatchAppInstalled else {
+            debug(.watchManager, "⌚️❌ Skipping setupWatchState - No Watch is paired or app not installed")
+            return WatchState(date: Date())
+        }
+
+        // Skip if watch session is not activated
+        guard session.activationState == .activated else {
+            debug(.watchManager, "⌚️❌ Skipping setupWatchState - Watch session not activated")
+            return WatchState(date: Date())
+        }
         do {
             // Get NSManagedObjectIDs
             let glucoseIds = try await fetchGlucose()
@@ -179,10 +213,10 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 }
 
                 // Set IOB and COB from latest determination
-                if let latestDetermination = determinationObjects.first {
-                    let iob = latestDetermination.iob ?? 0
-                    watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob)
+                let iob = self.iobService.currentIOB ?? 0
+                watchState.iob = Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob as NSNumber)
 
+                if let latestDetermination = determinationObjects.first {
                     let cob = NSNumber(value: latestDetermination.cob)
                     watchState.cob = Formatter.integerFormatter.string(from: cob)
                 }
@@ -527,7 +561,9 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             {
                 debug(.watchManager, "📱 Watch requested watch state data update.")
                 guard let self = self else { return }
-
+                // Skip if no watch is paired or app not installed
+                guard let session = self.session, session.isPaired, session.isReachable,
+                      session.isWatchAppInstalled else { return }
                 Task {
                     let state = await self.setupWatchState()
                     await self.sendDataToWatch(state)
@@ -1137,6 +1173,8 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
 extension BaseWatchManager: SettingsObserver, PumpSettingsObserver {
     // to update maxBolus
     func pumpSettingsDidChange(_: PumpSettings) {
+        // Skip if no watch is paired or app not installed
+        guard let session = self.session, session.isPaired, session.isReachable, session.isWatchAppInstalled else { return }
         Task {
             let state = await self.setupWatchState()
             await self.sendDataToWatch(state)
@@ -1150,6 +1188,9 @@ extension BaseWatchManager: SettingsObserver, PumpSettingsObserver {
         lowGlucose = settingsManager.settings.low
         highGlucose = settingsManager.settings.high
 
+        // Skip if no watch is paired or app not installed
+        guard let session = self.session, session.isPaired, session.isReachable, session.isWatchAppInstalled else { return }
+
         Task {
             let state = await self.setupWatchState()
             await self.sendDataToWatch(state)

+ 35 - 3
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -53,6 +53,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Stores, retrieves, and updates insulin dose determinations in CoreData.
     @Injected() private var determinationStorage: DeterminationStorage!
 
+    @Injected() private var iobService: IOBService!
+
     /// Persists the user's device list between app launches.
     @Persisted(key: "BaseGarminManager.persistedDevices") private var persistedDevices: [GarminDevice] = []
 
@@ -133,6 +135,27 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
             .receive(on: DispatchQueue.global(qos: .background))
             .sink { [weak self] _ in
                 guard let self = self else { return }
+                // Skip if no Garmin devices are connected
+                guard !self.devices.isEmpty else { return }
+                Task {
+                    do {
+                        let watchState = try await self.setupGarminWatchState()
+                        let watchStateData = try JSONEncoder().encode(watchState)
+                        self.sendWatchStateData(watchStateData)
+                    } catch {
+                        debug(
+                            .watchManager,
+                            "\(DebuggingIdentifiers.failed) Error updating watch state: \(error)"
+                        )
+                    }
+                }
+            }
+            .store(in: &subscriptions)
+
+        iobService.iobPublisher
+            .receive(on: DispatchQueue.global(qos: .background))
+            .sink { [weak self] _ in
+                guard let self = self else { return }
                 Task {
                     do {
                         let watchState = try await self.setupGarminWatchState()
@@ -160,6 +183,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
             .filteredByEntityName("OrefDetermination")
             .sink { [weak self] _ in
                 guard let self = self else { return }
+                // Skip if no Garmin devices are connected
+                guard !self.devices.isEmpty else { return }
                 Task {
                     do {
                         let watchState = try await self.setupGarminWatchState()
@@ -180,6 +205,8 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
             .filteredByEntityName("GlucoseStored")
             .sink { [weak self] _ in
                 guard let self = self else { return }
+                // Skip if no Garmin devices are connected
+                guard !self.devices.isEmpty else { return }
                 Task {
                     do {
                         let watchState = try await self.setupGarminWatchState()
@@ -219,6 +246,11 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     /// Builds a `GarminWatchState` reflecting the latest glucose, trend, delta, eventual BG, ISF, IOB, and COB.
     /// - Returns: A `GarminWatchState` containing the most recent device- and therapy-related info.
     func setupGarminWatchState() async throws -> GarminWatchState {
+        // Skip expensive calculations if no Garmin devices are connected
+        guard !devices.isEmpty else {
+            debug(.watchManager, "⌚️❌ Skipping setupGarminWatchState - No Garmin devices connected")
+            return GarminWatchState()
+        }
         do {
             // Get Glucose IDs
             let glucoseIds = try await fetchGlucose()
@@ -239,15 +271,15 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
                 var watchState = GarminWatchState()
 
                 /// Pull `glucose`, `trendRaw`, `delta`, `lastLoopDateInterval`, `iob`, `cob`,  `isf`, and `eventualBGRaw` from the latest determination.
+                let iobValue = self.iobService.currentIOB ?? 0
+                watchState.iob = self.iobFormatterWithOneFractionDigit(iobValue)
+
                 if let latestDetermination = determinationObjects.first {
                     watchState.lastLoopDateInterval = latestDetermination.timestamp.map {
                         guard $0.timeIntervalSince1970 > 0 else { return 0 }
                         return UInt64($0.timeIntervalSince1970)
                     }
 
-                    let iobValue = latestDetermination.iob ?? 0
-                    watchState.iob = self.iobFormatterWithOneFractionDigit(iobValue as Decimal)
-
                     let cobNumber = NSNumber(value: latestDetermination.cob)
                     watchState.cob = Formatter.integerFormatter.string(from: cobNumber)
 

+ 1 - 0
Trio/Sources/Shortcuts/BaseIntentsRequest.swift

@@ -16,6 +16,7 @@ import Swinject
     @Injected() var overrideStorage: OverrideStorage!
     @Injected() var liveActivityManager: LiveActivityManager!
     @Injected() var pumpHistoryStorage: PumpHistoryStorage!
+    @Injected() var iobService: IOBService!
 
     let resolver: Resolver
 

+ 5 - 5
Trio/Sources/Shortcuts/Carbs/AddCarbPresetIntent.swift

@@ -19,7 +19,7 @@ import Swinject
     ) var carbQuantity: Double?
 
     @Parameter(
-        title: "Quantity fat",
+        title: "Quantity Fat",
         description: "Quantity of fat in g",
         default: 0.0,
         inclusiveRange: (0, 200),
@@ -46,21 +46,21 @@ import Swinject
     ) var note: String?
 
     @Parameter(
-        title: "Confirm Before applying",
-        description: "If toggled, you will need to confirm before applying",
+        title: "Confirm Before logging",
+        description: "If toggled, you will need to confirm before logging",
         default: true
     ) var confirmBeforeApplying: Bool
 
     static var parameterSummary: some ParameterSummary {
         When(\.$confirmBeforeApplying, .equalTo, true, {
-            Summary("Applying \(\.$carbQuantity) at \(\.$dateAdded)") {
+            Summary("Log \(\.$carbQuantity) at \(\.$dateAdded)") {
                 \.$fatQuantity
                 \.$proteinQuantity
                 \.$note
                 \.$confirmBeforeApplying
             }
         }, otherwise: {
-            Summary("Immediately applying \(\.$carbQuantity) at \(\.$dateAdded)") {
+            Summary("Immediately Log \(\.$carbQuantity) at \(\.$dateAdded)") {
                 \.$fatQuantity
                 \.$proteinQuantity
                 \.$note

+ 1 - 1
Trio/Sources/Shortcuts/State/StateIntentRequest.swift

@@ -111,7 +111,7 @@ final class StateIntentRequest: BaseIntentsRequest {
             fetchLimit: 1
         ) as? [OrefDetermination] ?? []
 
-        let iobAsDouble = Double(truncating: (results.first?.iob ?? 0.0) as NSNumber)
+        let iobAsDouble = Double(truncating: (iobService.currentIOB ?? 0.0) as NSNumber)
         let cobAsDouble = Double(truncating: (results.first?.cob ?? 0) as NSNumber)
 
         return (iobAsDouble, cobAsDouble)

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

@@ -18,6 +18,8 @@ public struct TextFieldWithToolBar: View {
     var previousTextField: (() -> Void)?
     var nextTextField: (() -> Void)?
     var initialFocus: Bool
+    var unitsText: String?
+    var unitsTextColor: Color
 
     @FocusState private var isFocused: Bool
     @State private var localText: String = ""
@@ -40,7 +42,9 @@ public struct TextFieldWithToolBar: View {
         showArrows: Bool = false,
         previousTextField: (() -> Void)? = nil,
         nextTextField: (() -> Void)? = nil,
-        initialFocus: Bool = false
+        initialFocus: Bool = false,
+        unitsText: String? = nil,
+        unitsTextColor: Color = .secondary
     ) {
         _text = text
         self.placeholder = placeholder
@@ -59,150 +63,160 @@ public struct TextFieldWithToolBar: View {
         self.previousTextField = previousTextField
         self.nextTextField = nextTextField
         self.initialFocus = initialFocus
+        self.unitsText = unitsText
+        self.unitsTextColor = unitsTextColor
     }
 
     public var body: some View {
-        TextField(placeholder, text: $localText)
-            .focused($isFocused)
-            .multilineTextAlignment(textAlignment)
-            .foregroundColor(textColor)
-            .keyboardType(keyboardType)
-            .toolbar {
-                if isFocused {
-                    ToolbarItemGroup(placement: .keyboard) {
-                        Button(action: {
-                            localText = ""
-                            text = 0
-                            isZeroCleared = true // Mark as cleared to prevent showing "0"
-                            textDidChange?(0)
-                        }) {
-                            Image(systemName: "trash")
-                        }
-
-                        if showArrows {
-                            Button(action: { previousTextField?() }) {
-                                Image(systemName: "chevron.up")
+        HStack {
+            TextField(placeholder, text: $localText)
+                .focused($isFocused)
+                .multilineTextAlignment(textAlignment)
+                .foregroundColor(textColor)
+                .keyboardType(keyboardType)
+                .toolbar {
+                    if isFocused {
+                        ToolbarItemGroup(placement: .keyboard) {
+                            Button(action: {
+                                localText = ""
+                                text = 0
+                                isZeroCleared = true // Mark as cleared to prevent showing "0"
+                                textDidChange?(0)
+                            }) {
+                                Image(systemName: "trash")
                             }
-                            Button(action: { nextTextField?() }) {
-                                Image(systemName: "chevron.down")
+
+                            if showArrows {
+                                Button(action: { previousTextField?() }) {
+                                    Image(systemName: "chevron.up")
+                                }
+                                Button(action: { nextTextField?() }) {
+                                    Image(systemName: "chevron.down")
+                                }
                             }
-                        }
 
-                        Spacer()
+                            Spacer()
 
-                        if isDismissible {
-                            Button(action: { isFocused = false }) {
-                                Image(systemName: "keyboard.chevron.compact.down")
+                            if isDismissible {
+                                Button(action: { isFocused = false }) {
+                                    Image(systemName: "keyboard.chevron.compact.down")
+                                }
                             }
                         }
                     }
                 }
-            }
-            .onChange(of: isFocused) { _, newValue in
-                if newValue {
-                    textFieldDidBeginEditing?()
-                    // When gaining focus: if the value is zero and was previously cleared,
-                    // keep the text field empty to show placeholder instead of "0"
-                    if isZeroCleared, text == 0 {
-                        localText = ""
-                    }
-                } else {
-                    // When losing focus: handle formatting and validation
-                    if localText.isEmpty {
-                        // If field is empty, maintain zero value but mark as cleared
-                        // so we can show placeholder instead of "0"
-                        text = 0
-                        isZeroCleared = true
-                    } else if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
-                        if decimal != 0 {
-                            // For non-zero values, format normally and update binding
-                            text = decimal
-                            localText = numberFormatter.string(from: decimal as NSNumber) ?? ""
-                            isZeroCleared = false
-                        } else {
-                            // If user explicitly entered zero, store the value but
-                            // keep display empty to show placeholder
-                            text = 0
+                .onChange(of: isFocused) { _, newValue in
+                    if newValue {
+                        textFieldDidBeginEditing?()
+                        // When gaining focus: if the value is zero and was previously cleared,
+                        // keep the text field empty to show placeholder instead of "0"
+                        if isZeroCleared, text == 0 {
                             localText = ""
+                        }
+                    } else {
+                        // When losing focus: handle formatting and validation
+                        if localText.isEmpty {
+                            // If field is empty, maintain zero value but mark as cleared
+                            // so we can show placeholder instead of "0"
+                            text = 0
                             isZeroCleared = true
+                        } else if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
+                            if decimal != 0 {
+                                // For non-zero values, format normally and update binding
+                                text = decimal
+                                localText = numberFormatter.string(from: decimal as NSNumber) ?? ""
+                                isZeroCleared = false
+                            } else {
+                                // If user explicitly entered zero, store the value but
+                                // keep display empty to show placeholder
+                                text = 0
+                                localText = ""
+                                isZeroCleared = true
+                            }
                         }
                     }
                 }
-            }
-            .onChange(of: localText) { oldValue, newValue in
-                // Reset zero-cleared state as soon as user starts typing anything
-                if !newValue.isEmpty {
-                    isZeroCleared = false
-                }
-
-                // Special handling for backspace operations to maintain decimal format
-                if oldValue.count == newValue.count + 1 {
-                    let decimalSeparator = numberFormatter.decimalSeparator ?? "."
-
-                    // Special case: When backspacing to leave only a decimal point
-                    // e.g., "10.1" -> "10." - Keep decimal separator without adding trailing zero
-                    if newValue.hasSuffix(decimalSeparator) {
-                        if let decimal = Decimal(string: newValue + "0", locale: numberFormatter.locale) {
-                            text = decimal
-                            textDidChange?(decimal)
-                        }
-                        return
+                .onChange(of: localText) { oldValue, newValue in
+                    // Reset zero-cleared state as soon as user starts typing anything
+                    if !newValue.isEmpty {
+                        isZeroCleared = false
                     }
 
-                    // Special case: When backspacing the last digit after a decimal point
-                    // e.g., "10.0" -> "10." - Ensure we keep proper decimal format
-                    if oldValue.contains(decimalSeparator), newValue.contains(decimalSeparator) {
-                        let oldParts = oldValue.components(separatedBy: decimalSeparator)
-                        let newParts = newValue.components(separatedBy: decimalSeparator)
-
-                        // Check if we've removed the last digit after decimal point
-                        if oldParts.count > 1, newParts.count > 1,
-                           oldParts[1].count == 1, newParts[1].isEmpty
-                        {
-                            // Keep proper decimal format by adding trailing zero
-                            localText = newParts[0] + decimalSeparator + "0"
+                    // Special handling for backspace operations to maintain decimal format
+                    if oldValue.count == newValue.count + 1 {
+                        let decimalSeparator = numberFormatter.decimalSeparator ?? "."
 
-                            if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
+                        // Special case: When backspacing to leave only a decimal point
+                        // e.g., "10.1" -> "10." - Keep decimal separator without adding trailing zero
+                        if newValue.hasSuffix(decimalSeparator) {
+                            if let decimal = Decimal(string: newValue + "0", locale: numberFormatter.locale) {
                                 text = decimal
                                 textDidChange?(decimal)
                             }
                             return
                         }
+
+                        // Special case: When backspacing the last digit after a decimal point
+                        // e.g., "10.0" -> "10." - Ensure we keep proper decimal format
+                        if oldValue.contains(decimalSeparator), newValue.contains(decimalSeparator) {
+                            let oldParts = oldValue.components(separatedBy: decimalSeparator)
+                            let newParts = newValue.components(separatedBy: decimalSeparator)
+
+                            // Check if we've removed the last digit after decimal point
+                            if oldParts.count > 1, newParts.count > 1,
+                               oldParts[1].count == 1, newParts[1].isEmpty
+                            {
+                                // Keep proper decimal format by adding trailing zero
+                                localText = newParts[0] + decimalSeparator + "0"
+
+                                if let decimal = Decimal(string: localText, locale: numberFormatter.locale) {
+                                    text = decimal
+                                    textDidChange?(decimal)
+                                }
+                                return
+                            }
+                        }
                     }
-                }
 
-                // Process normal text input changes
-                handleTextChange(oldValue, newValue)
-            }
-            .onChange(of: text) { oldValue, newValue in
-                // Handle external changes to the text binding
-                // (changes not initiated by typing, like programmatic changes)
-                if oldValue != newValue,
-                   Decimal(string: localText, locale: numberFormatter.locale) != newValue
-                {
-                    if newValue == 0, isZeroCleared {
-                        // If value is zero and field was cleared, keep display empty to show placeholder
-                        localText = ""
-                    } else {
-                        // Otherwise format and display the new value
-                        localText = numberFormatter.string(from: newValue as NSNumber) ?? ""
-                        isZeroCleared = false
+                    // Process normal text input changes
+                    handleTextChange(oldValue, newValue)
+                }
+                .onChange(of: text) { oldValue, newValue in
+                    // Handle external changes to the text binding
+                    // (changes not initiated by typing, like programmatic changes)
+                    if oldValue != newValue,
+                       Decimal(string: localText, locale: numberFormatter.locale) != newValue
+                    {
+                        if newValue == 0, isZeroCleared {
+                            // If value is zero and field was cleared, keep display empty to show placeholder
+                            localText = ""
+                        } else {
+                            // Otherwise format and display the new value
+                            localText = numberFormatter.string(from: newValue as NSNumber) ?? ""
+                            isZeroCleared = false
+                        }
                     }
                 }
-            }
-            .onAppear {
-                if text != 0 {
-                    // Initialize with formatted non-zero value
-                    localText = numberFormatter.string(from: text as NSNumber) ?? ""
-                    isZeroCleared = false
-                } else {
-                    // For zero values, start with empty field to show placeholder
-                    localText = ""
-                    isZeroCleared = true
+                .onAppear {
+                    if text != 0 {
+                        // Initialize with formatted non-zero value
+                        localText = numberFormatter.string(from: text as NSNumber) ?? ""
+                        isZeroCleared = false
+                    } else {
+                        // For zero values, start with empty field to show placeholder
+                        localText = ""
+                        isZeroCleared = true
+                    }
+                    // Set initial focus if requested
+                    isFocused = initialFocus
                 }
-                // Set initial focus if requested
-                isFocused = initialFocus
+            if unitsText != nil {
+                Text(unitsText ?? "").foregroundColor(unitsTextColor)
+                    .onTapGesture {
+                        isFocused = true
+                    }
             }
+        }
     }
 
     private func handleTextChange(_ oldValue: String, _ newValue: String) {