Przeglądaj źródła

Merge branch 'dev' of github.com:nightscout/Trio-dev into filtering-treatments

Marvin Polscheit 7 miesięcy temu
rodzic
commit
1903c3127e
49 zmienionych plików z 1053 dodań i 498 usunięć
  1. 4 3
      .github/workflows/build_trio.yml
  2. 1 1
      Config.xcconfig
  3. 1 1
      DanaKit
  4. 1 1
      LibreTransmitter
  5. 1 1
      OmniBLE
  6. 1 1
      OmniKit
  7. 42 30
      Trio.xcodeproj/project.pbxproj
  8. 0 12
      Trio/Resources/InfoPlist.xcstrings
  9. 22 3
      Trio/Sources/APS/APSManager.swift
  10. 21 24
      Trio/Sources/APS/CGM/PluginSource.swift
  11. 40 0
      Trio/Sources/APS/FetchGlucoseManager.swift
  12. 27 6
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  13. 6 1
      Trio/Sources/APS/Storage/OverrideStorage.swift
  14. 1 0
      Trio/Sources/Application/TrioApp.swift
  15. 1 0
      Trio/Sources/Assemblies/ServiceAssembly.swift
  16. 4 1
      Trio/Sources/Helpers/Formatters.swift
  17. 84 20
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  18. 2 2
      Trio/Sources/Modules/Calibrations/View/CalibrationsRootView.swift
  19. 6 6
      Trio/Sources/Modules/DataTable/View/CarbEntryEditorView.swift
  20. 2 2
      Trio/Sources/Modules/DataTable/View/DataTableRootView.swift
  21. 1 1
      Trio/Sources/Modules/GeneralSettings/View/UnitsLimitsSettingsRootView.swift
  22. 0 7
      Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift
  23. 13 0
      Trio/Sources/Modules/Home/HomeStateModel.swift
  24. 1 1
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  25. 2 2
      Trio/Sources/Modules/ManualTempBasal/View/ManualTempBasalRootView.swift
  26. 1 1
      Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift
  27. 49 18
      Trio/Sources/Modules/Settings/View/SettingsRootView.swift
  28. 8 2
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/BolusStatsView.swift
  29. 11 5
      Trio/Sources/Modules/Stat/View/ViewElements/Insulin/TotalDailyDoseChart.swift
  30. 8 2
      Trio/Sources/Modules/Stat/View/ViewElements/Meal/MealStatsView.swift
  31. 8 19
      Trio/Sources/Modules/Treatments/View/ForecastChart.swift
  32. 6 6
      Trio/Sources/Modules/Treatments/View/MealPreset/AddMealPresetView.swift
  33. 8 8
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  34. 307 152
      Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift
  35. 2 1
      Trio/Sources/Services/Calendar/CalendarManager.swift
  36. 13 1
      Trio/Sources/Services/ContactImage/ContactImageManager.swift
  37. 115 0
      Trio/Sources/Services/IOB/IOBService.swift
  38. 1 2
      Trio/Sources/Services/LiveActivity/Data/DataManager.swift
  39. 0 1
      Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift
  40. 2 1
      Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift
  41. 10 0
      Trio/Sources/Services/LiveActivity/LiveActivityManager.swift
  42. 3 25
      Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift
  43. 1 0
      Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift
  44. 45 4
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  45. 35 3
      Trio/Sources/Services/WatchManager/GarminManager.swift
  46. 1 0
      Trio/Sources/Shortcuts/BaseIntentsRequest.swift
  47. 5 5
      Trio/Sources/Shortcuts/Carbs/AddCarbPresetIntent.swift
  48. 1 1
      Trio/Sources/Shortcuts/State/StateIntentRequest.swift
  49. 129 115
      Trio/Sources/Views/TextFieldWithToolBar.swift

+ 4 - 3
.github/workflows/build_trio.yml

@@ -7,8 +7,9 @@ on:
   #push:
 
   schedule:
-    - cron: "0 8 * * 3" # Checks for updates at 08:00 UTC every Wednesday
-    - cron: "0 6 1 * *" # Builds the app on the 1st of every month at 06:00 UTC
+    # avoid starting an action at xx:00 when GitHub resources are more likely to be impacted
+    - cron: "43 8 * * 3" # Checks for updates at 08:43 UTC every Wednesday
+    - cron: "43 6 1 * *" # Builds the app on the 1st of every month at 06:43 UTC
 
 env:
   UPSTREAM_REPO: nightscout/Trio
@@ -212,7 +213,7 @@ jobs:
       | # runs if started manually, or if sync schedule is set and enabled and scheduled on the first Saturday each month, or if sync schedule is set and enabled and new commits were found
       github.event_name == 'workflow_dispatch' ||
       (needs.check_alive_and_permissions.outputs.WORKFLOW_PERMISSION == 'true' &&
-        (vars.SCHEDULED_BUILD != 'false' && github.event.schedule == '0 6 1 * *') ||
+        (vars.SCHEDULED_BUILD != 'false' && github.event.schedule == '43 6 1 * *') ||
         (vars.SCHEDULED_SYNC != 'false' && needs.check_latest_from_upstream.outputs.NEW_COMMITS == 'true' )
       )
     steps:

+ 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.4
+APP_DEV_VERSION = 0.5.1.22
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 1 - 1
DanaKit

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

+ 1 - 1
LibreTransmitter

@@ -1 +1 @@
-Subproject commit 044cf70bd79813d47048291b740a599e1ab4ab40
+Subproject commit a80ffb4bbc1cc72778cbf4eb69e90b4ff63dd5bf

+ 1 - 1
OmniBLE

@@ -1 +1 @@
-Subproject commit 6f65cbae4c8089a892911e273204edfc4cc81e9d
+Subproject commit 97fe52f1a43edad69a80fccce5fddb10cc813b3d

+ 1 - 1
OmniKit

@@ -1 +1 @@
-Subproject commit 92948a7684ec382714becc53c643a1617597bb37
+Subproject commit 12058d3d0394cd4269468513d838e570faf5853b

+ 42 - 30
Trio.xcodeproj/project.pbxproj

@@ -256,6 +256,7 @@
 		3BD6CE262DC24CFD00FA0472 /* pumphistory-24h-zoned.json in Resources */ = {isa = PBXBuildFile; fileRef = 3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */; };
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
+		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
 		45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
 		491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */; };
@@ -1074,6 +1075,7 @@
 		3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "pumphistory-24h-zoned.json"; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
+		3BF85FE12E427312000D7351 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
 		42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorProvider.swift; sourceTree = "<group>"; };
@@ -2018,17 +2020,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 */,
+				3BF85FE22E427312000D7351 /* IOB */,
 				6B1A8D2C2B156EC100E76752 /* LiveActivity */,
 				3811DE9425C9D88200A708ED /* Network */,
 				38B4F3C425E5016800E76A18 /* Notifications */,
+				BD47FD112D88AA630043966B /* OnboardingManager */,
 				DD9ECB662CA99EFE00AA7C45 /* RemoteControl */,
 				38AEE75025F021F10013F05B /* SettingsManager */,
 				3811DE9825C9D88300A708ED /* Storage */,
@@ -2607,6 +2610,14 @@
 			path = JSONImporterData;
 			sourceTree = "<group>";
 		};
+		3BF85FE22E427312000D7351 /* IOB */ = {
+			isa = PBXGroup;
+			children = (
+				3BF85FE12E427312000D7351 /* IOBService.swift */,
+			);
+			path = IOB;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -2659,22 +2670,22 @@
 			isa = PBXGroup;
 			children = (
 				49B9B57E2D5768D2009C6B59 /* AdjustmentStored+Helper.swift */,
-				581516A82BCEEDF800BF67D7 /* NSPredicates.swift */,
-				583684052BD178DB00070A60 /* GlucoseStored+helper.swift */,
-				58F107732BD1A4D000B1A680 /* Determination+helper.swift */,
 				5837A52F2BD2E3C700A5DC04 /* CarbEntryStored+helper.swift */,
-				585E2CAD2BE7BF46006ECF1A /* PumpEvent+helper.swift */,
-				CC76E9502BD4812E008BEB61 /* Forecast+helper.swift */,
-				5887527B2BD986E1008B081D /* OpenAPSBattery.swift */,
-				581AC4382BE22ED10038760C /* JSONConverter.swift */,
-				BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */,
+				BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */,
 				582FAE422C05102C00D1C13F /* CoreDataError.swift */,
 				BDF34EBD2C0A31D000D51995 /* CustomNotification.swift */,
-				BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */,
+				58F107732BD1A4D000B1A680 /* Determination+helper.swift */,
+				CC76E9502BD4812E008BEB61 /* Forecast+helper.swift */,
+				583684052BD178DB00070A60 /* GlucoseStored+helper.swift */,
+				581AC4382BE22ED10038760C /* JSONConverter.swift */,
+				581516A82BCEEDF800BF67D7 /* NSPredicates.swift */,
+				5887527B2BD986E1008B081D /* OpenAPSBattery.swift */,
 				BD793CAF2CE7C60E00D669AC /* OverrideRunStored+helper.swift */,
-				BDB899892C565D0B006F3298 /* CarbsGlucose+helper.swift */,
-				58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */,
+				BDCD47AE2C1F3F1700F8BCD5 /* OverrideStored+helper.swift */,
+				585E2CAD2BE7BF46006ECF1A /* PumpEvent+helper.swift */,
 				BD793CB12CE8032E00D669AC /* TempTargetRunStored.swift */,
+				58A3D5432C96DE11003F90FC /* TempTargetStored+Helper.swift */,
+				BDB3C1182C03DD1000CEEAA1 /* UserDefaultsExtension.swift */,
 			);
 			path = Helper;
 			sourceTree = "<group>";
@@ -4322,6 +4333,7 @@
 				38BF021B25E7D06400579895 /* PumpSettingsView.swift in Sources */,
 				3811DEEA25CA063400A708ED /* SyncAccess.swift in Sources */,
 				190EBCC829FF13AA00BA767D /* UserInterfaceSettingsStateModel.swift in Sources */,
+				3BF85FE32E427312000D7351 /* IOBService.swift in Sources */,
 				DDF847EA2C5DABAC0049BB3B /* WatchConfigGarminView.swift in Sources */,
 				38BF021F25E7F0DE00579895 /* DeviceDataManager.swift in Sources */,
 				BD4E1A7A2D3681B700D21626 /* GlucoseTargetSetup.swift in Sources */,
@@ -5037,7 +5049,7 @@
 				ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = "$(APP_BUILD_NUMBER)";
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -5052,7 +5064,7 @@
 					"@executable_path/../../Frameworks",
 				);
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).LiveActivity";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -5071,7 +5083,7 @@
 				ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = "$(APP_BUILD_NUMBER)";
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -5086,7 +5098,7 @@
 					"@executable_path/../../Frameworks",
 				);
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).LiveActivity";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -5105,7 +5117,7 @@
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = "$(APP_BUILD_NUMBER)";
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -5120,7 +5132,7 @@
 					"@executable_path/../../../../Frameworks",
 				);
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).watchkitapp.TrioWatchComplication";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SDKROOT = watchos;
@@ -5142,7 +5154,7 @@
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = "$(APP_BUILD_NUMBER)";
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -5157,7 +5169,7 @@
 					"@executable_path/../../../../Frameworks",
 				);
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).watchkitapp.TrioWatchComplication";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SDKROOT = watchos;
@@ -5178,7 +5190,7 @@
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = "$(APP_BUILD_NUMBER)";
 				DEVELOPMENT_ASSET_PATHS = "\"Trio Watch App Extension/Preview Content\"";
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_PREVIEWS = YES;
@@ -5193,7 +5205,7 @@
 					"@executable_path/Frameworks",
 				);
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).watchkitapp";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SDKROOT = watchos;
@@ -5215,7 +5227,7 @@
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 				CODE_SIGN_IDENTITY = "Apple Development";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = "$(APP_BUILD_NUMBER)";
 				DEVELOPMENT_ASSET_PATHS = "\"Trio Watch App Extension/Preview Content\"";
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_PREVIEWS = YES;
@@ -5230,7 +5242,7 @@
 					"@executable_path/Frameworks",
 				);
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).watchkitapp";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SDKROOT = watchos;
@@ -5249,13 +5261,13 @@
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = "$(APP_BUILD_NUMBER)";
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
 				GENERATE_INFOPLIST_FILE = YES;
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).TrioWatchAppTests";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SDKROOT = watchos;
@@ -5275,13 +5287,13 @@
 				BUNDLE_LOADER = "$(TEST_HOST)";
 				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 1;
+				CURRENT_PROJECT_VERSION = "$(APP_BUILD_NUMBER)";
 				DEVELOPMENT_TEAM = "$(DEVELOPER_TEAM)";
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
 				GENERATE_INFOPLIST_FILE = YES;
 				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
-				MARKETING_VERSION = 1.0;
+				MARKETING_VERSION = "$(APP_VERSION)";
 				PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).TrioWatchAppTests";
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SDKROOT = watchos;

+ 0 - 12
Trio/Resources/InfoPlist.xcstrings

@@ -457,18 +457,6 @@
         }
       }
     },
-    "NSCalendarsFullAccessUsageDescription" : {
-      "comment" : "Privacy - Calendars Full Access Usage Description",
-      "extractionState" : "extracted_with_value",
-      "localizations" : {
-        "en" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay"
-          }
-        }
-      }
-    },
     "NSCalendarsUsageDescription" : {
       "comment" : "Privacy - Calendars Usage Description",
       "extractionState" : "extracted_with_value",

+ 22 - 3
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)
 
@@ -423,20 +425,26 @@ final class BaseAPSManager: APSManager, Injectable {
         // Fetch glucose asynchronously
         let glucose = try await fetchGlucose(predicate: NSPredicate.predicateForOneHourAgo, fetchLimit: 6)
 
+        var invalidGlucoseError: String?
+
         // Perform the context-related checks and actions
         let isValidGlucoseData = await privateContext.perform { [weak self] in
             guard let self else { return false }
 
             guard glucose.count > 2 else {
                 debug(.apsManager, "Not enough glucose data")
-                self.processError(APSError.glucoseError(message: String(localized: "Not enough glucose data")))
+                invalidGlucoseError =
+                    String(
+                        localized: "Not enough glucose data. You need at least three glucose readings in the last six hours to run the algorithm."
+                    )
                 return false
             }
 
             let dateOfLastGlucose = glucose.first?.date
             guard dateOfLastGlucose ?? Date() >= Date().addingTimeInterval(-12.minutes.timeInterval) else {
                 debug(.apsManager, "Glucose data is stale")
-                self.processError(APSError.glucoseError(message: String(localized: "Glucose data is stale")))
+                invalidGlucoseError =
+                    String(localized: "Glucose data is stale. The most recent glucose reading is from more than 12 minutes ago.")
                 return false
             }
 
@@ -453,6 +461,7 @@ final class BaseAPSManager: APSManager, Injectable {
             _ = try await autosenseResult
             try await openAPS.createProfiles()
             let determination = try await openAPS.determineBasal(currentTemp: await currentTemp, clock: now)
+            iobFileDidUpdate.send(())
 
             guard isValidGlucoseData else {
                 throw APSError.glucoseError(message: "Glucose validation failed")
@@ -468,7 +477,17 @@ final class BaseAPSManager: APSManager, Injectable {
                 }
             }
         } catch {
-            throw APSError.apsError(message: "Error determining basal: \(error.localizedDescription)")
+            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
+            // takes priority
+            if let invalidGlucoseError = invalidGlucoseError {
+                throw APSError.apsError(message: invalidGlucoseError)
+            } else {
+                throw APSError.apsError(message: "Error determining basal: \(error.localizedDescription)")
+            }
         }
     }
 

+ 21 - 24
Trio/Sources/APS/CGM/PluginSource.swift

@@ -15,8 +15,6 @@ final class PluginSource: GlucoseSource {
 
     var cgmHasValidSensorSession: Bool = false
 
-    private var promise: Future<[BloodGlucose], Error>.Promise?
-
     init(glucoseStorage: GlucoseStorage, glucoseManager: FetchGlucoseManager) {
         self.glucoseStorage = glucoseStorage
         self.glucoseManager = glucoseManager
@@ -34,25 +32,12 @@ final class PluginSource: GlucoseSource {
     /// - Parameter timer: An optional `DispatchTimer` (not used in the function but can be used to trigger fetch logic).
     /// - Returns: An `AnyPublisher` that emits an array of `BloodGlucose` values or an empty array if an error occurs or the timeout is reached.
     func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
-        Publishers.Merge(
-            callBLEFetch(),
-            fetchIfNeeded()
-        )
-        .filter { !$0.isEmpty }
-        .first()
-        .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
-        .replaceError(with: [])
-        .eraseToAnyPublisher()
-    }
-
-    func callBLEFetch() -> AnyPublisher<[BloodGlucose], Never> {
-        Future<[BloodGlucose], Error> { [weak self] promise in
-            self?.promise = promise
-        }
-        .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
-        .replaceError(with: [])
-        .replaceEmpty(with: [])
-        .eraseToAnyPublisher()
+        fetchIfNeeded()
+            .filter { !$0.isEmpty }
+            .first()
+            .timeout(60 * 5, scheduler: processQueue, options: nil, customError: nil)
+            .replaceError(with: [])
+            .eraseToAnyPublisher()
     }
 
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
@@ -60,8 +45,14 @@ final class PluginSource: GlucoseSource {
             guard let self = self else { return }
             self.processQueue.async {
                 guard let cgmManager = self.cgmManager else { return }
-                cgmManager.fetchNewDataIfNeeded { result in
-                    promise(self.readCGMResult(readingResult: result))
+                cgmManager.fetchNewDataIfNeeded { _ in
+                    // Ignore values returned from fetchNewDataIfNeeded since
+                    // these come from share client and cause a race condition
+                    // that causes the promise to complete before a CGM value
+                    // has a chance to return. From looking at the code this should
+                    // only impact G6 since that is the only CGM manager that will
+                    // return data and only if share credentials are set
+                    promise(.success([]))
                 }
             }
         }
@@ -123,7 +114,13 @@ extension PluginSource: CGMManagerDelegate {
 
             dispatchPrecondition(condition: .onQueue(self.processQueue))
 
-            self.promise?(self.readCGMResult(readingResult: readingResult))
+            switch self.readCGMResult(readingResult: readingResult) {
+            case let .success(glucose):
+                self.glucoseManager?.newGlucoseFromCgmManager(newGlucose: glucose)
+            case .failure:
+                debug(.deviceManager, "CGM PLUGIN - unable to read CGM result")
+            }
+
             debug(.deviceManager, "CGM PLUGIN - Direct return done")
         }
     }

+ 40 - 0
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -11,6 +11,7 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     func updateGlucoseSource(cgmGlucoseSourceType: CGMType, cgmGlucosePluginId: String, newManager: CGMManagerUI?)
     func deleteGlucoseSource() async
     func removeCalibrations()
+    func newGlucoseFromCgmManager(newGlucose: [BloodGlucose])
     var glucoseSource: GlucoseSource? { get }
     var cgmManager: CGMManagerUI? { get }
     var cgmGlucoseSourceType: CGMType { get set }
@@ -55,6 +56,9 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
 
     private let context = CoreDataStack.shared.newTaskContext()
 
+    /// Enforce mutual exclusion on calls to glucoseStoreAndHeartDecision
+    private let glucoseStoreAndHeartLock = DispatchSemaphore(value: 1)
+
     var shouldSyncToRemoteService: Bool {
         guard let cgmManager = cgmManager else {
             return true
@@ -95,6 +99,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                 )
                 .eraseToAnyPublisher()
                 .sink { newGlucose, syncDate in
+                    self.glucoseStoreAndHeartLock.wait()
                     Task {
                         do {
                             try await self.glucoseStoreAndHeartDecision(
@@ -104,6 +109,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
                         } catch {
                             debug(.deviceManager, "Failed to store glucose: \(error)")
                         }
+                        self.glucoseStoreAndHeartLock.signal()
                     }
                 }
                 .store(in: &self.lifetime)
@@ -113,6 +119,28 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         timer.resume()
     }
 
+    /// Store new glucose readings from the CGM manager
+    ///
+    /// This function enables plugin CGM managers to send new glucose readings directly
+    /// to the FetchGlucoseManager, bypassing the Combine pipeline. By bypassing the
+    /// Combine pipeline CGM managers can send backfill glucose readings, which come
+    /// right after a new glucose reading, typically.
+    func newGlucoseFromCgmManager(newGlucose: [BloodGlucose]) {
+        glucoseStoreAndHeartLock.wait()
+        let syncDate = glucoseStorage.syncDate()
+        Task {
+            do {
+                try await glucoseStoreAndHeartDecision(
+                    syncDate: syncDate,
+                    glucose: newGlucose
+                )
+            } catch {
+                debug(.deviceManager, "Failed to store glucose from CGM manager: \(error)")
+            }
+            glucoseStoreAndHeartLock.signal()
+        }
+    }
+
     var glucoseSource: GlucoseSource?
 
     func removeCalibrations() {
@@ -256,6 +284,18 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             return
         }
 
+        // TODO: Fix backfill logic https://github.com/nightscout/Trio/issues/737
+        /*
+         let backfillGlucose = newGlucose.filter { $0.dateString <= syncDate }
+         if backfillGlucose.isNotEmpty {
+             debug(.deviceManager, "Backfilling glucose...")
+             do {
+                 try await glucoseStorage.storeGlucose(backfillGlucose)
+             } catch {
+                 debug(.deviceManager, "Unable to backfill glucose: \(error)")
+             }
+         }*/
+
         filteredByDate = newGlucose.filter { $0.dateString > syncDate }
         filtered = glucoseStorage.filterTooFrequentGlucose(filteredByDate, at: syncDate)
 

+ 27 - 6
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -82,25 +82,46 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
         }
     }
 
+    /// filter out duplicate CGM readings
+    ///
+    /// This function will look through existing stored CGM values and filter out any new CGM values that
+    /// already exist. It does matching using dates and adds a small amount of time buffer for matching (1 second)
+    /// to account for precision loss that can happen with backfill CGM readings.
     private func filterNewGlucoseValues(_ glucose: [BloodGlucose]) -> [BloodGlucose] {
-        let datesToCheck: Set<Date?> = Set(glucose.compactMap { $0.dateString as Date? })
+        let datesToCheck = glucose.map(\.dateString).sorted()
+        guard let firstDate = datesToCheck.first.map({ $0.addingTimeInterval(-1) }),
+              let lastDate = datesToCheck.last.map({ $0.addingTimeInterval(1) })
+        else {
+            return glucose
+        }
         let fetchRequest: NSFetchRequest<NSFetchRequestResult> = GlucoseStored.fetchRequest()
         fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
-            NSPredicate(format: "date IN %@", datesToCheck),
-            NSPredicate.predicateForOneDayAgo
+            NSPredicate(format: "date >= %@", firstDate as NSDate),
+            NSPredicate(format: "date <= %@", lastDate as NSDate)
         ])
         fetchRequest.propertiesToFetch = ["date"]
         fetchRequest.resultType = .dictionaryResultType
 
-        var existingDates = Set<Date>()
+        var existingDates = [Date]()
         do {
             let results = try context.fetch(fetchRequest) as? [NSDictionary]
-            existingDates = Set(results?.compactMap({ $0["date"] as? Date }) ?? [])
+            existingDates = results?.compactMap({ $0["date"] as? Date }) ?? []
         } catch {
             debugPrint("Failed to fetch existing glucose dates: \(error)")
         }
 
-        return glucose.filter { !existingDates.contains($0.dateString) }
+        // This is an inefficient filtering algorithm, but I'm assuming that the
+        // time spans are short and that duplicates are rare, so in the common
+        // case there won't be any existing dates.
+        return glucose.filter { glucose in
+            for existingDate in existingDates {
+                let difference = abs(existingDate.timeIntervalSince(glucose.dateString))
+                if difference <= 1 {
+                    return false
+                }
+            }
+            return true
+        }
     }
 
     private func storeGlucoseInCoreData(_ glucose: [BloodGlucose]) throws {

+ 6 - 1
Trio/Sources/APS/Storage/OverrideStorage.swift

@@ -172,13 +172,18 @@ final class BaseOverrideStorage: @preconcurrency OverrideStorage, Injectable {
     /// otherwise we would edit the Preset
     @MainActor func copyRunningOverride(_ override: OverrideStored) async -> NSManagedObjectID {
         let newOverride = OverrideStored(context: viewContext)
+        newOverride.id = override.id
         newOverride.duration = override.duration
         newOverride.indefinite = override.indefinite
         newOverride.percentage = override.percentage
         newOverride.smbIsOff = override.smbIsOff
         newOverride.name = override.name
         newOverride.isPreset = false // no Preset
-        newOverride.date = override.date
+        newOverride.date = override.date?
+            .addingTimeInterval(
+                1.seconds
+                    .timeInterval
+            ) // hacky solution to show the copied override as the latest override and at the same time not modify an already running preset duration
         newOverride.enabled = override.enabled
         newOverride.target = override.target
         newOverride.advancedSettings = override.advancedSettings

+ 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
     }()
 

+ 84 - 20
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" : {
@@ -8832,9 +8836,6 @@
         }
       }
     },
-    "%lld h" : {
-
-    },
     "%lld hr" : {
       "localizations" : {
         "bg" : {
@@ -13720,7 +13721,7 @@
         }
       }
     },
-    "• Manual boluses you enter yoursef" : {
+    "• Manual boluses you enter yourself" : {
 
     },
     "• Manual temporary basal rates you set yourself" : {
@@ -20557,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" : {
@@ -37421,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" : {
@@ -39341,6 +39338,7 @@
       }
     },
     "Applying ${carbQuantity} at ${dateAdded}" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -62240,6 +62238,16 @@
         }
       }
     },
+    "Confirm Before logging" : {
+      "localizations" : {
+        "fr" : {
+          "stringUnit" : {
+            "state" : "translated",
+            "value" : "Confirmer avant d’enregistrer"
+          }
+        }
+      }
+    },
     "Confirm Bolus Faster" : {
       "localizations" : {
         "bg" : {
@@ -102963,7 +102971,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" : {
@@ -104888,6 +104896,7 @@
       }
     },
     "Glucose data is stale" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -104993,6 +105002,9 @@
         }
       }
     },
+    "Glucose data is stale. The most recent glucose reading is from more than 12 minutes ago." : {
+
+    },
     "Glucose data is too flat" : {
       "extractionState" : "stale",
       "localizations" : {
@@ -115062,6 +115074,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" : {
@@ -116347,6 +116369,7 @@
       }
     },
     "Immediately applying ${carbQuantity} at ${dateAdded}" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -116664,6 +116687,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",
@@ -124685,6 +124718,12 @@
         }
       }
     },
+    "Latest dev: %@" : {
+
+    },
+    "Latest dev: Fetching..." : {
+
+    },
     "Latest Raw Algorithm Output" : {
       "localizations" : {
         "bg" : {
@@ -128246,6 +128285,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" : {
@@ -149677,6 +149726,7 @@
       }
     },
     "Not enough glucose data" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -149782,6 +149832,9 @@
         }
       }
     },
+    "Not enough glucose data. You need at least three glucose readings in the last six hours to run the algorithm." : {
+
+    },
     "Not looping." : {
       "localizations" : {
         "bg" : {
@@ -167843,6 +167896,7 @@
       }
     },
     "Quantity fat" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -167948,6 +168002,9 @@
         }
       }
     },
+    "Quantity Fat" : {
+
+    },
     "Quantity of carbs in g" : {
       "localizations" : {
         "bg" : {
@@ -202256,7 +202313,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" : {
@@ -223904,7 +223968,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" : {
@@ -224117,7 +224181,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

@@ -560,9 +560,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")
                         }
                     }

+ 0 - 7
Trio/Sources/Modules/Home/HomeStateModel+Setup/OverrideSetup.swift

@@ -39,13 +39,6 @@ extension Home.StateModel {
         overrides = objects
     }
 
-    @MainActor func calculateDuration(override: OverrideStored) -> TimeInterval {
-        guard let overrideDuration = override.duration as? Double, overrideDuration != 0 else {
-            return TimeInterval(60 * 60 * 24) // one day
-        }
-        return TimeInterval(overrideDuration * 60) // return seconds
-    }
-
     // Setup expired Overrides
     func setupOverrideRunStored() {
         Task {

+ 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) {