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

Merge branch 'dev' of github.com:nightscout/Trio-dev into oref-swift

Deniz Cengiz 1 год назад
Родитель
Сommit
a6db8fbca8
47 измененных файлов с 722 добавлено и 572 удалено
  1. 4 4
      Trio.xcodeproj/project.pbxproj
  2. 0 1
      Trio/Resources/json/defaults/freeaps/freeaps_settings.json
  3. 2 2
      Trio/Sources/APS/APSManager.swift
  4. 2 2
      Trio/Sources/APS/FetchGlucoseManager.swift
  5. 1 0
      Trio/Sources/Application/AppDelegate.swift
  6. 9 4
      Trio/Sources/Application/TrioApp.swift
  7. 30 2
      Trio/Sources/Helpers/BuildDetails.swift
  8. 22 6
      Trio/Sources/Helpers/CustomProgressView.swift
  9. 162 6
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  10. 6 0
      Trio/Sources/Models/Determination.swift
  11. 1 0
      Trio/Sources/Models/NightscoutStatus.swift
  12. 0 16
      Trio/Sources/Models/TimeInRangeChartStyle.swift
  13. 2 7
      Trio/Sources/Models/TrioSettings.swift
  14. 5 1
      Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift
  15. 60 47
      Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift
  16. 5 1
      Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift
  17. 4 6
      Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift
  18. 18 7
      Trio/Sources/Modules/DataTable/View/DataTableRootView.swift
  19. 79 0
      Trio/Sources/Modules/Home/HomeStateModel+CGM.swift
  20. 26 64
      Trio/Sources/Modules/Home/HomeStateModel.swift
  21. 6 6
      Trio/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift
  22. 29 37
      Trio/Sources/Modules/Settings/SettingItems.swift
  23. 6 1
      Trio/Sources/Modules/Settings/SettingsStateModel.swift
  24. 18 6
      Trio/Sources/Modules/Settings/View/SettingsRootView.swift
  25. 25 13
      Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift
  26. 9 13
      Trio/Sources/Modules/Stat/StatStateModel.swift
  27. 2 2
      Trio/Sources/Modules/Stat/View/StatRootView.swift
  28. 20 12
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseMetricsView.swift
  29. 2 8
      Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopBarChartView.swift
  30. 6 1
      Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopStatsView.swift
  31. 0 58
      Trio/Sources/Modules/StatConfig/StatConfigStateModel.swift
  32. 0 113
      Trio/Sources/Modules/StatConfig/View/StatConfigRootView.swift
  33. 4 2
      Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift
  34. 16 10
      Trio/Sources/Modules/Treatments/View/ForecastChart.swift
  35. 1 1
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  36. 0 3
      Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift
  37. 5 45
      Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
  38. 76 11
      Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
  39. 1 1
      Trio/Sources/Services/RemoteControl/TrioRemoteControl+APNS.swift
  40. 2 2
      Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift
  41. 18 16
      Trio/Sources/Shortcuts/Bolus/BolusIntent.swift
  42. 3 6
      Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift
  43. 2 2
      Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntent.swift
  44. 16 17
      Trio/Sources/Shortcuts/Override/ApplyOverridePresetIntent.swift
  45. 3 3
      Trio/Sources/Shortcuts/Override/CancelOverrideIntent.swift
  46. 2 4
      Trio/Sources/Shortcuts/TempPresets/ApplyTempPresetIntent.swift
  47. 12 3
      Trio/Sources/Views/SettingInputSection.swift

+ 4 - 4
Trio.xcodeproj/project.pbxproj

@@ -237,6 +237,7 @@
 		3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C42D4AECD500CE213C /* ProfileIsfTests.swift */; };
 		3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */; };
 		3B5F45B62D6A239500F70982 /* DoubleApproximateMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */; };
+		3B4196E02D8C4BC00091DFF7 /* HomeStateModel+CGM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */; };
 		3BAD36B22D7CDC1A00CC298D /* MainLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */; };
 		3BAD36CC2D7D420E00CC298D /* CoreDataInitializationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */; };
 		3BC26E552D7418830066ACD6 /* IobSuspendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */; };
@@ -490,7 +491,6 @@
 		D6D02515BBFBE64FEBE89856 /* DataTableRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 881E04BA5E0A003DE8E0A9C6 /* DataTableRootView.swift */; };
 		D6DEC113821A7F1056C4AA1E /* NightscoutConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2A13DF0EDEEEDC4106AA2A /* NightscoutConfigDataFlow.swift */; };
 		DBA5254DBB2586C98F61220C /* ISFEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9F137F126D9F8DEB799F26 /* ISFEditorProvider.swift */; };
-		DD07CA142CE80B73002D45A9 /* TimeInRangeChartStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD07CA132CE80B73002D45A9 /* TimeInRangeChartStyle.swift */; };
 		DD09D47B2C5986D1003FEA5D /* CalendarEventSettingsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09D47A2C5986D1003FEA5D /* CalendarEventSettingsDataFlow.swift */; };
 		DD09D47D2C5986DA003FEA5D /* CalendarEventSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09D47C2C5986DA003FEA5D /* CalendarEventSettingsProvider.swift */; };
 		DD09D47F2C5986E5003FEA5D /* CalendarEventSettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD09D47E2C5986E5003FEA5D /* CalendarEventSettingsStateModel.swift */; };
@@ -1000,6 +1000,7 @@
 		3B5CD2C52D4AECD500CE213C /* ProfileJavascriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileJavascriptTests.swift; sourceTree = "<group>"; };
 		3B5CD2C62D4AECD500CE213C /* ProfileTargetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTargetsTests.swift; sourceTree = "<group>"; };
 		3B5F45B52D6A239000F70982 /* DoubleApproximateMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleApproximateMatching.swift; sourceTree = "<group>"; };
+		3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeStateModel+CGM.swift"; sourceTree = "<group>"; };
 		3BAD36B12D7CDC1400CC298D /* MainLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainLoadingView.swift; sourceTree = "<group>"; };
 		3BAD36CB2D7D420500CC298D /* CoreDataInitializationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataInitializationCoordinator.swift; sourceTree = "<group>"; };
 		3BC26E542D7418830066ACD6 /* IobSuspendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IobSuspendTests.swift; sourceTree = "<group>"; };
@@ -1259,7 +1260,6 @@
 		CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalStateModel.swift; sourceTree = "<group>"; };
 		D0BDC6993C1087310EDFC428 /* CarbRatioEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarbRatioEditorRootView.swift; sourceTree = "<group>"; };
 		DC2C6489D29ECCCAD78E0721 /* GlucoseNotificationSettingsStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationSettingsStateModel.swift; sourceTree = "<group>"; };
-		DD07CA132CE80B73002D45A9 /* TimeInRangeChartStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeInRangeChartStyle.swift; sourceTree = "<group>"; };
 		DD09D47A2C5986D1003FEA5D /* CalendarEventSettingsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventSettingsDataFlow.swift; sourceTree = "<group>"; };
 		DD09D47C2C5986DA003FEA5D /* CalendarEventSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventSettingsProvider.swift; sourceTree = "<group>"; };
 		DD09D47E2C5986E5003FEA5D /* CalendarEventSettingsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventSettingsStateModel.swift; sourceTree = "<group>"; };
@@ -1839,6 +1839,7 @@
 				3811DE2A25C9D49500A708ED /* HomeDataFlow.swift */,
 				3811DE2925C9D49500A708ED /* HomeProvider.swift */,
 				3811DE2825C9D49500A708ED /* HomeStateModel.swift */,
+				3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */,
 				58645B972CA2D16A008AFCE7 /* HomeStateModel+Setup */,
 				3811DE2C25C9D49500A708ED /* View */,
 			);
@@ -2191,7 +2192,6 @@
 				BD54A95A2D28087700F9C1EE /* OverridePresetWatch.swift */,
 				BDA25EFC2D261BF200035F34 /* WatchState.swift */,
 				715120D12D3C2B84005D9FB6 /* GlucoseNotificationsOption.swift */,
-				DD07CA132CE80B73002D45A9 /* TimeInRangeChartStyle.swift */,
 				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
 				DD6D67E32C9C253500660C9B /* ColorSchemeOption.swift */,
 				388E5A5F25B6F2310019842D /* Autosens.swift */,
@@ -3916,7 +3916,6 @@
 				382C134B25F14E3700715CE1 /* BGTargets.swift in Sources */,
 				38AEE75725F0F18E0013F05B /* CarbsStorage.swift in Sources */,
 				38B4F3CA25E502E200E76A18 /* SwiftNotificationCenter.swift in Sources */,
-				DD07CA142CE80B73002D45A9 /* TimeInRangeChartStyle.swift in Sources */,
 				38AEE75225F022080013F05B /* SettingsManager.swift in Sources */,
 				3894873A2614928B004DF424 /* DispatchTimer.swift in Sources */,
 				3895E4C625B9E00D00214B37 /* Preferences.swift in Sources */,
@@ -4105,6 +4104,7 @@
 				CE7950242997D81700FA576E /* CGMSettingsView.swift in Sources */,
 				58237D9E2BCF0A6B00A47A79 /* PopupView.swift in Sources */,
 				BD793CB02CE7C61500D669AC /* OverrideRunStored+helper.swift in Sources */,
+				3B4196E02D8C4BC00091DFF7 /* HomeStateModel+CGM.swift in Sources */,
 				38D0B3D925EC07C400CB6E88 /* CarbsEntry.swift in Sources */,
 				DD32CF9C2CC82499003686D6 /* TrioRemoteControl+TempTarget.swift in Sources */,
 				BD249D882D42FC0000412DEB /* BolusStatsView.swift in Sources */,

+ 0 - 1
Trio/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -33,7 +33,6 @@
   "glucoseColorScheme" : "staticColor",
   "xGridLines" : true,
   "yGridLines" : true,
-  "timeInRangeChartStyle" : "vertical",
   "rulerMarks" : true,
   "forecastDisplayType": "cone",
   "maxCarbs": 250,

+ 2 - 2
Trio/Sources/APS/APSManager.swift

@@ -950,11 +950,11 @@ final class BaseAPSManager: APSManager, Injectable {
             }
             let af = pref.adjustmentFactor
             let insulin_type = pref.curve
-            let buildDate = BuildDetails.default.buildDate()
+            let buildDate = BuildDetails.shared.buildDate()
             let version = Bundle.main.releaseVersionNumber
             let build = Bundle.main.buildVersionNumber
 
-            var branch = BuildDetails.default.branchAndSha
+            var branch = BuildDetails.shared.branchAndSha
 
             let copyrightNotice_ = Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String ?? ""
             let pump_ = pumpManager?.localizedTitle ?? ""

+ 2 - 2
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -122,12 +122,12 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     @MainActor func deleteGlucoseSource() async {
         cgmManager = nil
         glucoseSource = nil
+        settingsManager.settings.cgm = cgmDefaultModel.type
+        settingsManager.settings.cgmPluginIdentifier = cgmDefaultModel.id
         updateGlucoseSource(
             cgmGlucoseSourceType: cgmDefaultModel.type,
             cgmGlucosePluginId: cgmDefaultModel.id
         )
-        settingsManager.settings.cgm = cgmDefaultModel.type
-        settingsManager.settings.cgmPluginIdentifier = cgmDefaultModel.id
     }
 
     func saveConfigManager() {

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

@@ -31,6 +31,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, ObservableObject, UNUserNoti
                         .default,
                         "\(DebuggingIdentifiers.failed) failed to handle remote notification with error: \(error.localizedDescription)"
                     )
+                    completionHandler(.failed)
                 }
             }
         } catch {

+ 9 - 4
Trio/Sources/Application/TrioApp.swift

@@ -80,13 +80,13 @@ extension Notification.Name {
     }
 
     init() {
-        let submodulesInfo = BuildDetails.default.submodules.map { key, value in
+        let submodulesInfo = BuildDetails.shared.submodules.map { key, value in
             "\(key): \(value.branch) \(value.commitSHA)"
         }.joined(separator: ", ")
 
         debug(
             .default,
-            "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.default.buildDate()))] [buildExpires: \(String(describing: BuildDetails.default.calculateExpirationDate()))] [submodules: \(submodulesInfo)]"
+            "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.shared.buildDate()))] [buildExpires: \(String(describing: BuildDetails.shared.calculateExpirationDate()))] [submodules: \(submodulesInfo)]"
         )
 
         // Fix bug in iOS 18 related to the translucent tab bar
@@ -104,7 +104,7 @@ extension Notification.Name {
             do {
                 try await coreDataStack.initializeStack()
 
-                await MainActor.run {
+                await Task { @MainActor in
                     // Only load services after successful Core Data initialization
                     loadServices()
 
@@ -114,7 +114,12 @@ extension Notification.Name {
                     self.initState.complete = true
                     Foundation.NotificationCenter.default.post(name: .initializationCompleted, object: nil)
                     UIApplication.shared.registerForRemoteNotifications()
-                }
+                    do {
+                        try await BuildDetails.shared.handleExpireDateChange()
+                    } catch {
+                        debug(.default, "Failed to handle expire date change: \(error)")
+                    }
+                }.value
             } catch {
                 debug(
                     .coreData,

+ 30 - 2
Trio/Sources/Helpers/BuildDetails.swift

@@ -1,9 +1,12 @@
 import Foundation
+import Swinject
 
-class BuildDetails {
-    static var `default` = BuildDetails()
+class BuildDetails: Injectable {
+    static var shared = BuildDetails()
+    @Injected() internal var nightscoutManager: NightscoutManager!
 
     let dict: [String: Any]
+    let previousExpireDateKey = "previousExpireDate"
 
     init() {
         guard let url = Bundle.main.url(forResource: "BuildDetails", withExtension: "plist"),
@@ -89,4 +92,29 @@ class BuildDetails {
             return String(localized: "App Expires")
         }
     }
+
+    // Upload new profile if expire date has changed
+    func handleExpireDateChange() async throws
+    {
+        if nightscoutManager == nil {
+            await injectServices(TrioApp.resolver)
+        }
+
+        let previousExpireDate = UserDefaults.standard.object(forKey: previousExpireDateKey) as? Date
+        let expireDate = calculateExpirationDate()
+
+        if previousExpireDate != expireDate {
+            debug(.nightscout, "New build expire date detected, uploading profile")
+            try await nightscoutManager.uploadProfiles()
+        }
+    }
+
+    // Store the uploaded expire date
+    func recordUploadedExpireDate(expireDate: Date?) {
+        if let expireDate = expireDate {
+            UserDefaults.standard.set(expireDate, forKey: previousExpireDateKey)
+        } else {
+            UserDefaults.standard.removeObject(forKey: previousExpireDateKey)
+        }
+    }
 }

+ 22 - 6
Trio/Sources/Helpers/CustomProgressView.swift

@@ -43,10 +43,26 @@ struct CustomProgressView: View {
     }
 }
 
-enum ProgressText: String {
-    case updatingIOB = "Updating IOB ..."
-    case updatingCOB = "Updating COB ..."
-    case updatingHistory = "Updating History ..."
-    case updatingTreatments = "Updating Treatments ..."
-    case updatingIOBandCOB = "Updating IOB and COB ..."
+enum ProgressText: CaseIterable {
+    case updatingIOB
+    case updatingCOB
+    case updatingHistory
+    case updatingTreatments
+    case updatingIOBandCOB
+
+    var displayName: String {
+        switch self {
+        case .updatingIOB:
+            return String(localized: "Updating IOB ...", comment: "Status message for updating IOB")
+        case .updatingCOB:
+
+            return String(localized: "Updating COB ...", comment: "Status message for updating COB")
+        case .updatingHistory:
+            return String(localized: "Updating History ...", comment: "Status message for updating history")
+        case .updatingTreatments:
+            return String(localized: "Updating Treatments ...", comment: "Status message for updating treatments")
+        case .updatingIOBandCOB:
+            return String(localized: "Updating IOB and COB ...", comment: "Status message for updating both IOB and COB")
+        }
+    }
 }

+ 162 - 6
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -2193,6 +2193,9 @@
         }
       }
     },
+    " SMB" : {
+      "comment" : "Super Micro Bolus indicator in delete alert"
+    },
     " SMBs are disabled either by schedule or during the entire duration." : {
       "comment" : "Alert string. Keep spaces.",
       "extractionState" : "manual",
@@ -17323,6 +17326,9 @@
         }
       }
     },
+    "A bolus command of %@ U of insulin was sent." : {
+
+    },
     "A display of On/On indicates both Dynamic ISF and CR are enabled. On/Off indicates only Dynamic ISF is enabled. Dynamic CR cannot be enabled when Dynamic ISF is disabled." : {
       "localizations" : {
         "bg" : {
@@ -18739,8 +18745,12 @@
         }
       }
     },
+    "Activate an override" : {
+
+    },
     "Activate Dynamic Carb Ratio (CR)" : {
       "comment" : "Enable Dyn CR",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -19142,6 +19152,7 @@
     },
     "Activate Dynamic Sensitivity (ISF)" : {
       "comment" : "Enable Dyn ISF",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -19658,6 +19669,9 @@
         }
       }
     },
+    "Add a CGM and pump to enable automated insuin delivery" : {
+
+    },
     "Add a Garmin Device to Trio." : {
       "localizations" : {
         "bg" : {
@@ -25577,6 +25591,7 @@
     },
     "Adjustment Factor" : {
       "comment" : "Headline \"Adjustment Factor\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -26708,6 +26723,7 @@
       }
     },
     "Algorithm" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -27223,6 +27239,9 @@
         }
       }
     },
+    "All FPUs and the carbs of the meal will be deleted." : {
+      "comment" : "Alert message for meal deletion"
+    },
     "All FPUs of the meal will be deleted." : {
       "extractionState" : "manual",
       "localizations" : {
@@ -27750,6 +27769,7 @@
       }
     },
     "Allow Fetching From Nightscout" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -28362,6 +28382,7 @@
       }
     },
     "Allow SMB With High Temporary Target" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -28962,6 +28983,9 @@
         }
       }
     },
+    "Allow to send a bolus to the app" : {
+
+    },
     "Allow Trio to read from and write to Apple Health." : {
       "localizations" : {
         "bg" : {
@@ -33552,6 +33576,9 @@
         }
       }
     },
+    "Applying ${bolusQuantity} U" : {
+
+    },
     "Applying ${carbQuantity} at ${dateAdded}" : {
       "localizations" : {
         "bg" : {
@@ -33752,6 +33779,9 @@
         }
       }
     },
+    "Applying ${preset} override" : {
+
+    },
     "Are you sure to add %@ g of carbs ?" : {
       "localizations" : {
         "bg" : {
@@ -33852,6 +33882,9 @@
         }
       }
     },
+    "Are you sure you want to bolus %@ U of insulin?" : {
+
+    },
     "Are you sure you want to delete %@?" : {
       "localizations" : {
         "bg" : {
@@ -39715,6 +39748,12 @@
         }
       }
     },
+    "Bolus amount (units of insulin)?" : {
+
+    },
+    "Bolus amount in U" : {
+
+    },
     "Bolus Calculator" : {
       "localizations" : {
         "bg" : {
@@ -41161,6 +41200,9 @@
         }
       }
     },
+    "Bolusing via Shortcuts is disabled in Trio settings." : {
+
+    },
     "Bottom target" : {
       "comment" : "Bottom target temp",
       "extractionState" : "manual",
@@ -43019,6 +43061,9 @@
         }
       }
     },
+    "Cancel an active override" : {
+
+    },
     "Cancel Bolus" : {
       "localizations" : {
         "bg" : {
@@ -43119,6 +43164,9 @@
         }
       }
     },
+    "Cancel override" : {
+
+    },
     "Cancel Override" : {
       "localizations" : {
         "bg" : {
@@ -48240,6 +48288,7 @@
       }
     },
     "Choose the orientation of the Time in Range Chart." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -48339,7 +48388,7 @@
         }
       }
     },
-    "Choose to display eA1c in percent or mmol/mol." : {
+    "Choose to display eA1c and GMI in percent or mmol/mol." : {
 
     },
     "Choose to display HbA1c in percent or mmol/mol." : {
@@ -48643,7 +48692,7 @@
         }
       }
     },
-    "Choose which format you'd prefer the eA1c (estimated A1c) value in the statistics view as a percentage (Example: 6.5%) or mmol/mol (Example: 48 mmol/mol)." : {
+    "Choose which format you'd prefer the eA1c (estimated A1c) and GMI (Glucose Management Index) value in the statistics view as a percentage (Example: eA1c: 6.5%) or mmol/mol (Example: eA1c: 48 mmol/mol)." : {
 
     },
     "Choose which format you'd prefer the HbA1c value in the statistics view as a percentage (Example: 6.5%) or mmol/mol (Example: 48 mmol/mol)." : {
@@ -48748,6 +48797,7 @@
       }
     },
     "Choose which style for the time in range chart you'd prefer: a standing, i.e., vertical, bar chart or a laying, i.e., horizontal, line chart." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -51612,6 +51662,9 @@
         }
       }
     },
+    "Confirm to apply override '%@'" : {
+
+    },
     "Confirm to apply Temporary Target '%@'" : {
       "localizations" : {
         "bg" : {
@@ -53657,6 +53710,7 @@
       }
     },
     "Create Calendar Events" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -55611,6 +55665,7 @@
 
     },
     "Dark Mode" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -55711,6 +55766,7 @@
       }
     },
     "Dark Scheme" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -62724,6 +62780,9 @@
         }
       }
     },
+    "Delete Carbs Equivalents?" : {
+      "comment" : "Alert title for deleting carb equivalents"
+    },
     "Delete Carbs?" : {
       "comment" : "Delete carbs from data table and Nightscout",
       "extractionState" : "manual",
@@ -63779,6 +63838,9 @@
         }
       }
     },
+    "Delete the Temp Target Preset \"%@\"?" : {
+      "comment" : "Delete confirmation title for temporary target presets"
+    },
     "Delivery limits" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -65319,6 +65381,7 @@
       }
     },
     "DIA" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -66556,6 +66619,7 @@
       }
     },
     "Display and Allow Fat and Protein Entries" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -67693,6 +67757,7 @@
       }
     },
     "Display on Watch" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -70976,7 +71041,7 @@
     "eA1c" : {
 
     },
-    "eA1c Display Unit" : {
+    "eA1c/GMI Display Unit" : {
 
     },
     "Edit" : {
@@ -72741,6 +72806,7 @@
       }
     },
     "Enable Fatty Meal Factor" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -74055,6 +74121,7 @@
       }
     },
     "Enable SMB With Temporary Target" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -74364,6 +74431,7 @@
       }
     },
     "Enable Super Bolus" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -83239,6 +83307,7 @@
       }
     },
     "Fat and Protein Factor" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -83944,6 +84013,7 @@
     },
     "Fatty Meal Factor" : {
       "comment" : "For the  Bolus View pop-up",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -84144,6 +84214,7 @@
       }
     },
     "Features" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -84344,6 +84415,7 @@
       }
     },
     "Fetch and Remote Control" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -87188,6 +87260,7 @@
       }
     },
     "FPU" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -88916,7 +88989,7 @@
       }
     },
     "Glucose" : {
-      "comment" : "Glucose\nHistory Mode",
+      "comment" : "Glucose\nHistory Mode\nTitle for glucose-related statistics",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -93572,6 +93645,7 @@
       }
     },
     "High BG Target" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -94189,6 +94263,7 @@
     },
     "High Temptarget Raises Sensitivity" : {
       "comment" : "Headline \"High Temptarget Raises Sensitivity\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -95007,6 +95082,7 @@
       }
     },
     "Horizontal" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -96847,6 +96923,9 @@
         }
       }
     },
+    "If toggled, you will need to confirm before applying." : {
+
+    },
     "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" : {
@@ -97653,6 +97732,9 @@
         }
       }
     },
+    "Immediately applying ${bolusQuantity} U" : {
+
+    },
     "Immediately applying ${carbQuantity} at ${dateAdded}" : {
       "localizations" : {
         "bg" : {
@@ -97853,6 +97935,9 @@
         }
       }
     },
+    "Immediately applying ${preset} override" : {
+
+    },
     "Import Error" : {
       "comment" : "Import Error HeadlineImport Error Headline",
       "extractionState" : "manual",
@@ -100522,6 +100607,7 @@
       }
     },
     "Insulin" : {
+      "comment" : "Title for insulin-related statistics",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -105975,6 +106061,7 @@
 
     },
     "Light Mode" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -106075,6 +106162,7 @@
       }
     },
     "Light Scheme" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -109331,6 +109419,9 @@
         }
       }
     },
+    "Looping" : {
+      "comment" : "Title for looping and system statistics"
+    },
     "Looping Chart Type" : {
 
     },
@@ -110600,6 +110691,7 @@
     },
     "Low Temptarget Lowers Sensitivity" : {
       "comment" : "Headline ”Low Temptarget Lowers Sensitivity\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -112336,6 +112428,9 @@
         }
       }
     },
+    "Max bolus" : {
+
+    },
     "Max Bolus" : {
       "comment" : "Max setting",
       "localizations" : {
@@ -114896,6 +114991,7 @@
     },
     "Max UAM SMB Basal Minutes" : {
       "comment" : "Headline \"Max UAM SMB Basal Minutes\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -115497,6 +115593,7 @@
       }
     },
     "Maximum Duration (hours)" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -116435,7 +116532,7 @@
 
     },
     "Meals" : {
-      "comment" : "History Mode",
+      "comment" : "History Mode\nTitle for meal-related statistics",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -117767,6 +117864,7 @@
     },
     "Min 5m Carbimpact" : {
       "comment" : "Headline \"Min 5m Carbimpact\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -121070,6 +121168,7 @@
       }
     },
     "Nightscout Fetch & Remote Control" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -121371,6 +121470,7 @@
       }
     },
     "Nightscout Upload" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -124485,6 +124585,9 @@
         }
       }
     },
+    "Not allowed" : {
+
+    },
     "Not paired yet" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -130608,7 +130711,16 @@
         }
       }
     },
-    "Override eA1c Unit" : {
+    "Override '%@' applied" : {
+
+    },
+    "Override '%@' failed" : {
+
+    },
+    "Override canceled" : {
+
+    },
+    "Override choice" : {
 
     },
     "Override HbA1c Unit" : {
@@ -141002,6 +141114,7 @@
     },
     "Remaining Carbs Fraction" : {
       "comment" : "Headline \"Remaining Carbs Fraction\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -142208,6 +142321,9 @@
     "Restart Live Activity" : {
 
     },
+    "Restarts Trio's Live Activity" : {
+
+    },
     "Result" : {
       "comment" : "For the  Bolus View pop-up",
       "localizations" : {
@@ -146931,6 +147047,9 @@
         }
       }
     },
+    "Select override" : {
+
+    },
     "Select Pump Model" : {
       "localizations" : {
         "bg" : {
@@ -149750,6 +149869,7 @@
       }
     },
     "Set low and high glucose values for the main screen glucose graph and statistics." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -149849,6 +149969,9 @@
         }
       }
     },
+    "Set low and high glucose values for the main screen, watch app and live activity glucose graph." : {
+
+    },
     "Set Rate" : {
       "localizations" : {
         "bg" : {
@@ -153121,6 +153244,7 @@
       }
     },
     "Show Protein and Fat" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -157488,6 +157612,7 @@
       }
     },
     "Spread Interval (minutes)" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -157689,6 +157814,7 @@
       }
     },
     "Standing / Laying TIR Chart" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -161356,6 +161482,7 @@
       }
     },
     "Super Bolus Factor" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -165159,6 +165286,12 @@
         }
       }
     },
+    "Temporary Target '%@' applied" : {
+
+    },
+    "Temporary Target '%@' failed" : {
+
+    },
     "Temporary Target canceled" : {
       "localizations" : {
         "bg" : {
@@ -165766,6 +165899,9 @@
         }
       }
     },
+    "The bolus cannot be larger than the pump setting max bolus (%@)." : {
+
+    },
     "The current version has a critical issue and should be updated as soon as possible." : {
       "comment" : "Message for critical update alert",
       "localizations" : {
@@ -169498,6 +169634,7 @@
       }
     },
     "Therapy" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -176852,6 +176989,7 @@
       }
     },
     "Time in Range Chart Style" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -186414,6 +186552,18 @@
         }
       }
     },
+    "Updating COB ..." : {
+      "comment" : "Status message for updating COB"
+    },
+    "Updating History ..." : {
+      "comment" : "Status message for updating history"
+    },
+    "Updating IOB ..." : {
+      "comment" : "Status message for updating IOB"
+    },
+    "Updating IOB and COB ..." : {
+      "comment" : "Status message for updating both IOB and COB"
+    },
     "Updating IOB..." : {
       "comment" : "Progress text when updating IOB",
       "localizations" : {
@@ -186515,6 +186665,9 @@
         }
       }
     },
+    "Updating Treatments ..." : {
+      "comment" : "Status message for updating treatments"
+    },
     "Updating..." : {
       "comment" : "Updating Watch app",
       "extractionState" : "manual",
@@ -189286,6 +189439,7 @@
       }
     },
     "Vertical" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -190815,6 +190969,7 @@
       }
     },
     "Watch Complication" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -194705,6 +194860,7 @@
       }
     },
     "X-Axis Interval Step" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {

+ 6 - 0
Trio/Sources/Models/Determination.swift

@@ -19,6 +19,11 @@ struct Determination: JSON, Equatable {
     let reservoir: Decimal?
     var isf: Decimal?
     var timestamp: Date?
+
+    /// `tdd` (Total Daily Dose) is included so it can be part of the
+    /// enacted and suggested devicestatus data that gets uploaded to Nightscout.
+    var tdd: Decimal?
+
     var current_target: Decimal?
     let insulinForManualBolus: Decimal?
     let manualBolusErrorString: Decimal?
@@ -59,6 +64,7 @@ extension Determination {
         case timestamp
         case isf = "ISF"
         case current_target
+        case tdd = "TDD"
         case insulinForManualBolus
         case manualBolusErrorString
         case minDelta

+ 1 - 0
Trio/Sources/Models/NightscoutStatus.swift

@@ -58,6 +58,7 @@ struct NightscoutProfileStore: JSON {
     let isAPNSProduction: Bool
     let overridePresets: [NightscoutPresetOverride]?
     let teamID: String
+    let expirationDate: Date?
 }
 
 struct NightscoutPresetOverride: JSON {

+ 0 - 16
Trio/Sources/Models/TimeInRangeChartStyle.swift

@@ -1,16 +0,0 @@
-import Foundation
-
-enum TimeInRangeChartStyle: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
-    var id: String { rawValue }
-    case vertical
-    case horizontal
-
-    var displayName: String {
-        switch self {
-        case .vertical:
-            return String(localized: "Vertical", comment: "")
-        case .horizontal:
-            return String(localized: "Horizontal", comment: "")
-        }
-    }
-}

+ 2 - 7
Trio/Sources/Models/TrioSettings.swift

@@ -8,9 +8,9 @@ enum BolusShortcutLimit: String, JSON, CaseIterable, Identifiable {
     var displayName: String {
         switch self {
         case .notAllowed:
-            return String(localized: "Not allowed", table: "ShortcutsDetail")
+            return String(localized: "Not allowed")
         case .limitBolusMax:
-            return String(localized: "Max bolus", table: "ShortcutsDetail")
+            return String(localized: "Max bolus")
         }
     }
 }
@@ -54,7 +54,6 @@ struct TrioSettings: JSON, Equatable {
     var glucoseColorScheme: GlucoseColorScheme = .staticColor
     var xGridLines: Bool = true
     var yGridLines: Bool = true
-    var timeInRangeChartStyle: TimeInRangeChartStyle = .vertical
     var rulerMarks: Bool = true
     var forecastDisplayType: ForecastDisplayType = .cone
     var maxCarbs: Decimal = 250
@@ -253,10 +252,6 @@ extension TrioSettings: Decodable {
             settings.yGridLines = yGridLines
         }
 
-        if let timeInRangeChartStyle = try? container.decode(TimeInRangeChartStyle.self, forKey: .timeInRangeChartStyle) {
-            settings.timeInRangeChartStyle = timeInRangeChartStyle
-        }
-
         if let rulerMarks = try? container.decode(Bool.self, forKey: .rulerMarks) {
             settings.rulerMarks = rulerMarks
         }

+ 5 - 1
Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift

@@ -96,7 +96,11 @@ extension Adjustments.RootView {
     }
 
     private var deleteConfirmationTitle: String {
-        "Delete the Temp Target Preset \"\(selectedTempTarget?.name ?? "")\"?"
+        let presetName = selectedTempTarget?.name ?? ""
+        return String(
+            localized: "Delete the Temp Target Preset \"\(presetName)\"?",
+            comment: "Delete confirmation title for temporary target presets"
+        )
     }
 
     private func deleteConfirmationButtons() -> some View {

+ 60 - 47
Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift

@@ -4,6 +4,9 @@ import G7SensorKit
 import LoopKitUI
 import SwiftUI
 
+/// For a full description of the events that can happen for the CGM lifecycle, see comment at the top
+/// of HomeStateModel+CGM since these are the same events
+
 struct CGMModel: Identifiable, Hashable {
     var id: String
     var type: CGMType
@@ -23,18 +26,6 @@ let cgmDefaultModel = CGMModel(
     subtitle: CGMType.none.subtitle
 )
 
-struct OtherCGMSourceCompletionNotifying: CompletionNotifying {
-    var completionDelegate: (any LoopKitUI.CompletionDelegate)?
-}
-
-class CGMSetupCompletionNotifying: CompletionNotifying {
-    var completionDelegate: (any LoopKitUI.CompletionDelegate)?
-}
-
-class CGMDeletionCompletionNotifying: CompletionNotifying {
-    var completionDelegate: (any LoopKitUI.CompletionDelegate)?
-}
-
 extension CGMSettings {
     final class StateModel: BaseStateModel<Provider> {
         // Singleton implementation
@@ -49,7 +40,7 @@ extension CGMSettings {
 
         @Injected() var fetchGlucoseManager: FetchGlucoseManager!
         @Injected() var pluginCGMManager: PluginManager!
-        @Injected() private var broadcaster: Broadcaster!
+        @Injected() var broadcaster: Broadcaster!
         @Injected() var nightscoutManager: NightscoutManager!
 
         @Published var units: GlucoseUnits = .mgdL
@@ -60,8 +51,11 @@ extension CGMSettings {
         @Published var listOfCGM: [CGMModel] = []
         @Published var url: URL?
 
+        var shouldRunDeleteOnSettingsChange = true
+
         override func subscribe() {
             units = settingsManager.settings.units
+            broadcaster.register(SettingsObserver.self, observer: self)
 
             // collect the list of CGM available with plugins and CGMType defined manually
             listOfCGM = (
@@ -122,28 +116,36 @@ extension CGMSettings {
             subscribeSetting(\.smoothGlucose, on: $smoothGlucose, initial: { smoothGlucose = $0 })
         }
 
+        // this function will get called for all CGM types (plugin and non plugin)
         func addCGM(cgm: CGMModel) {
             cgmCurrent = cgm
-            switch cgmCurrent.type {
+            switch cgm.type {
             case .plugin:
                 shouldDisplayCGMSetupSheet.toggle()
             default:
-                fetchGlucoseManager.cgmGlucoseSourceType = cgmCurrent.type
-                completionNotifyingDidComplete(OtherCGMSourceCompletionNotifying())
+                // non plugin CGM types should be considered onboarded right away
+                shouldDisplayCGMSetupSheet = true
+                settingsManager.settings.cgm = cgmCurrent.type
+                settingsManager.settings.cgmPluginIdentifier = ""
+                fetchGlucoseManager.updateGlucoseSource(cgmGlucoseSourceType: cgmCurrent.type, cgmGlucosePluginId: cgmCurrent.id)
+                broadcaster.notify(GlucoseObserver.self, on: .main) {
+                    $0.glucoseDidUpdate([])
+                }
             }
         }
 
+        // Note: This function does _not_ get called for plugin CGMs
+        // instead, they will get cgmManagerWantsDeletion events which
+        // are handled by PluginSource
         func deleteCGM() {
-            fetchGlucoseManager.performOnCGMManagerQueue {
-                // Call plugin functionality on the manager queue (or at least attempt to)
-                Task {
-                    await self.fetchGlucoseManager?.deleteGlucoseSource()
-                }
+            Task {
+                await self.fetchGlucoseManager?.deleteGlucoseSource()
 
-                // UI updates go back to Main
-                DispatchQueue.main.async {
+                await MainActor.run {
                     self.shouldDisplayCGMSetupSheet = false
-                    self.completionNotifyingDidComplete(CGMDeletionCompletionNotifying())
+                    broadcaster.notify(GlucoseObserver.self, on: .main) {
+                        $0.glucoseDidUpdate([])
+                    }
                 }
             }
         }
@@ -152,40 +154,36 @@ extension CGMSettings {
 
 extension CGMSettings.StateModel: CompletionDelegate {
     func completionNotifyingDidComplete(_: CompletionNotifying) {
-        // if CGM was deleted
-        if fetchGlucoseManager.cgmGlucoseSourceType == .none {
-            cgmCurrent = cgmDefaultModel
-            settingsManager.settings.cgm = cgmDefaultModel.type
-            settingsManager.settings.cgmPluginIdentifier = cgmDefaultModel.id
-            Task {
-                await fetchGlucoseManager.deleteGlucoseSource()
-            }
-            shouldDisplayCGMSetupSheet = false
-        } else {
-            settingsManager.settings.cgm = cgmCurrent.type
-            settingsManager.settings.cgmPluginIdentifier = cgmCurrent.id
-            fetchGlucoseManager.updateGlucoseSource(cgmGlucoseSourceType: cgmCurrent.type, cgmGlucosePluginId: cgmCurrent.id)
-            shouldDisplayCGMSetupSheet = cgmCurrent.type == .simulator || cgmCurrent.type == .nightscout || cgmCurrent
-                .type == .xdrip || cgmCurrent.type == .enlite
-        }
-
-        // update glucose source if required
-        DispatchQueue.main.async {
-            self.broadcaster.notify(GlucoseObserver.self, on: .main) {
-                $0.glucoseDidUpdate([])
+        Task {
+            // this sleep is because this event and cgmManagerWantsDeletion
+            // are called in parallel.
+            try await Task.sleep(for: .seconds(0.2))
+            await MainActor.run {
+                if fetchGlucoseManager.cgmGlucoseSourceType == .none {
+                    cgmCurrent = cgmDefaultModel
+                }
             }
         }
+        shouldDisplayCGMSetupSheet = false
     }
 }
 
 extension CGMSettings.StateModel: CGMManagerOnboardingDelegate {
     func cgmManagerOnboarding(didCreateCGMManager manager: LoopKitUI.CGMManagerUI) {
-        // update the glucose source
+        // cgmCurrent should have been set in addCGM
+        debug(.service, "didCreateCGMManager called \(cgmCurrent)")
+        settingsManager.settings.cgm = cgmCurrent.type
+        settingsManager.settings.cgmPluginIdentifier = cgmCurrent.id
         fetchGlucoseManager.updateGlucoseSource(
             cgmGlucoseSourceType: cgmCurrent.type,
             cgmGlucosePluginId: cgmCurrent.id,
             newManager: manager
         )
+        DispatchQueue.main.async {
+            self.broadcaster.notify(GlucoseObserver.self, on: .main) {
+                $0.glucoseDidUpdate([])
+            }
+        }
     }
 
     func cgmManagerOnboarding(didOnboardCGMManager _: LoopKitUI.CGMManagerUI) {
@@ -193,8 +191,23 @@ extension CGMSettings.StateModel: CGMManagerOnboardingDelegate {
     }
 }
 
-extension CGMSettings.StateModel {
+extension CGMSettings.StateModel: SettingsObserver {
     func settingsDidChange(_: TrioSettings) {
         units = settingsManager.settings.units
+        // Deletes are handled differently for plugins vs non plugins
+        // but both will call deleteGlucoseSource on the fetchGlucoseManager
+        // so we listen for changes to the cgm setting and update our internal
+        // state accordingly
+        if settingsManager.settings.cgm == .none, shouldRunDeleteOnSettingsChange {
+            shouldRunDeleteOnSettingsChange = false
+            cgmCurrent = cgmDefaultModel
+            DispatchQueue.main.async {
+                self.broadcaster.notify(GlucoseObserver.self, on: .main) {
+                    $0.glucoseDidUpdate([])
+                }
+            }
+        } else {
+            shouldRunDeleteOnSettingsChange = true
+        }
     }
 }

+ 5 - 1
Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift

@@ -161,7 +161,11 @@ extension CGMSettings {
                                 completionDelegate: state,
                                 setupDelegate: state,
                                 pluginCGMManager: self.state.pluginCGMManager
-                            )
+                            ).onDisappear {
+                                if state.fetchGlucoseManager.cgmGlucoseSourceType == .none {
+                                    state.cgmCurrent = cgmDefaultModel
+                                }
+                            }
                         }
                     }
                 }

+ 4 - 6
Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift

@@ -140,12 +140,10 @@ extension CGMSettings {
                                 .padding(.vertical)
                         }
 
-                        if state.url == nil {
-                            NavigationLink(
-                                destination: NightscoutConfig.RootView(resolver: resolver, displayClose: false),
-                                label: { Text("Configure Nightscout").foregroundStyle(Color.accentColor) }
-                            )
-                        }
+                        NavigationLink(
+                            destination: NightscoutConfig.RootView(resolver: resolver, displayClose: false),
+                            label: { Text("Configure Nightscout").foregroundStyle(Color.accentColor) }
+                        )
                     }
                 ).listRowBackground(Color.chart)
 

+ 18 - 7
Trio/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -101,7 +101,7 @@ extension DataTable {
                 // Show custom progress view
                 /// don't show it if glucose is stale as it will block the UI
                 if state.waitForSuggestion && state.isGlucoseDataFresh(glucoseStored.first?.date) {
-                    CustomProgressView(text: progressText.rawValue)
+                    CustomProgressView(text: progressText.displayName)
                 }
             })
                 .background(appState.trioBackgroundColor(for: colorScheme))
@@ -367,7 +367,7 @@ extension DataTable {
                                 action: {
                                     alertGlucoseToDelete = glucose
 
-                                    alertTitle = "Delete Glucose?"
+                                    alertTitle = String(localized: "Delete Glucose?", comment: "Alert title for deleting glucose")
                                     alertMessage = Formatter.dateFormatter
                                         .string(from: glucose.date ?? Date()) + ", " +
                                         (Formatter.decimalFormatterWithTwoFractionDigits.string(for: glucose.glucose) ?? "0")
@@ -526,7 +526,7 @@ extension DataTable {
                         role: .none,
                         action: {
                             alertTreatmentToDelete = item
-                            alertTitle = "Delete Insulin?"
+                            alertTitle = String(localized: "Delete Insulin?", comment: "Alert title for deleting insulin")
                             alertMessage = Formatter.dateFormatter
                                 .string(from: item.timestamp ?? Date()) + ", " +
                                 (Formatter.decimalFormatterWithTwoFractionDigits.string(from: item.bolus?.amount ?? 0) ?? "0") +
@@ -534,7 +534,11 @@ extension DataTable {
 
                             if let bolus = item.bolus {
                                 // Add text snippet, so that alert message is more descriptive for SMBs
-                                alertMessage += bolus.isSMB ? " SMB" : ""
+                                alertMessage += bolus.isSMB ? String(
+                                    localized: " SMB",
+                                    comment: "Super Micro Bolus indicator in delete alert"
+                                )
+                                    : ""
                             }
 
                             isRemoveHistoryItemAlertPresented = true
@@ -603,7 +607,7 @@ extension DataTable {
 
                         // meal is carb-only
                         if meal.fpuID == nil {
-                            alertTitle = "Delete Carbs?"
+                            alertTitle = String(localized: "Delete Carbs?", comment: "Alert title for deleting carbs")
                             alertMessage = Formatter.dateFormatter
                                 .string(from: meal.date ?? Date()) + ", " +
                                 (Formatter.decimalFormatterWithTwoFractionDigits.string(for: meal.carbs) ?? "0") +
@@ -611,8 +615,15 @@ extension DataTable {
                         }
                         // meal is complex-meal or fpu-only
                         else {
-                            alertTitle = meal.isFPU ? "Delete Carbs Equivalents?" : "Delete Carbs?"
-                            alertMessage = "All FPUs and the carbs of the meal will be deleted."
+                            alertTitle = meal.isFPU ? String(
+                                localized: "Delete Carbs Equivalents?",
+                                comment: "Alert title for deleting carb equivalents"
+                            )
+                                : String(localized: "Delete Carbs?", comment: "Alert title for deleting carbs")
+                            alertMessage = String(
+                                localized: "All FPUs and the carbs of the meal will be deleted.",
+                                comment: "Alert message for meal deletion"
+                            )
                         }
 
                         isRemoveHistoryItemAlertPresented = true

+ 79 - 0
Trio/Sources/Modules/Home/HomeStateModel+CGM.swift

@@ -0,0 +1,79 @@
+import LoopKitUI
+
+/// Notes on the CGM lifecycle:
+/// There are two classes of CGM devices: plugins and non-plugins. Plugins are implemented using
+/// LoopKit APIs and include most hardware CGMs like Dexcom G6, G7, Libre, and so on. Non-plugins
+/// drivers are implemented directly in Trio, and include the CGM Simulator and Nightscout CGM. For
+/// these different CGMs, there are a few different events, handled in different places, that happen to
+/// signify a change in the CGM lifecycle.
+///
+/// Both:
+/// - addCGM function invocation: Called by the UI in response to a user clicking the "add CGM" button
+///
+/// Non-plugins only:
+/// - deleteCGM function invocation: Called by the CGM View in response to a user clicking the "delete CGM" button
+///
+/// Plugins only:
+/// - completionNotifyingDidComplete: Called by the CGM driver to signify that Trio should close its UIViewController
+/// - cgmManagerOnboarding didCreateCGMManager: Called by the CGM driver after adding a new CGM
+/// - cgmManagerWantsDeletion: Called by the CGM driver when the user asks to delete a CGM
+/// There are no ordering constraints between completionNotifyingDidComplete and the other two
+/// Plugin events (it's up to the implementation of each individual driver). For example, the G7 driver invokes
+/// cgmManagerWantsDeletion on the delegate's queue while calling completionNotifyingDidComplete in parallel
+/// on the main queue.
+///
+/// In additinon to having different events for different types of CGMs, the handling of these events is spread out
+/// across various state managers, like HomeStateModel, CGMSettingsStateModel, and PluginSource.
+///
+/// There is CGM state in the HomeStateModel and CGMSettingsStateModel, FetchGlucoseManager, and
+/// SettingsManger
+///
+/// The flow for adding a CGM:
+/// - Non-plugin: addCGM (considered onboarded at this point)
+/// - Plugin: addCGM -> cgmManagerOnboarding (after success)
+///
+/// For deleting a CGM:
+/// - Non-plugin: deleteCGM (in HomeStateModel and CGMSettingsStateModel)
+/// - Plugin: cgmManagerWantsDeletion (in PluginSource)
+/// Then, both non-plugin and plugin:  set settings.cgm (in FetchGlucoseManager) ->
+///     settingsDidChange (in HomeStateModel and CGMSettingsStateModel)
+
+extension Home.StateModel: CompletionDelegate {
+    /// This completion handler is called by both the CGM and the pump
+    func completionNotifyingDidComplete(_ notifying: CompletionNotifying) {
+        debug(.service, "Completion fired by: \(type(of: notifying))")
+        Task {
+            // this sleep is because this event and cgmManagerWantsDeletion
+            // are called in parallel.
+            try await Task.sleep(for: .seconds(0.2))
+            await MainActor.run {
+                if fetchGlucoseManager.cgmGlucoseSourceType == .none {
+                    cgmCurrent = cgmDefaultModel
+                }
+            }
+        }
+        shouldDisplayCGMSetupSheet = false
+        shouldDisplayPumpSetupSheet = false
+    }
+}
+
+extension Home.StateModel: CGMManagerOnboardingDelegate {
+    func cgmManagerOnboarding(didCreateCGMManager manager: LoopKitUI.CGMManagerUI) {
+        settingsManager.settings.cgm = cgmCurrent.type
+        settingsManager.settings.cgmPluginIdentifier = cgmCurrent.id
+        fetchGlucoseManager.updateGlucoseSource(
+            cgmGlucoseSourceType: cgmCurrent.type,
+            cgmGlucosePluginId: cgmCurrent.id,
+            newManager: manager
+        )
+        DispatchQueue.main.async {
+            self.broadcaster.notify(GlucoseObserver.self, on: .main) {
+                $0.glucoseDidUpdate([])
+            }
+        }
+    }
+
+    func cgmManagerOnboarding(didOnboardCGMManager _: LoopKitUI.CGMManagerUI) {
+        // nothing to do
+    }
+}

+ 26 - 64
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -103,6 +103,7 @@ extension Home {
         var cgmAvailable: Bool = false
         var listOfCGM: [CGMModel] = []
         var cgmCurrent = cgmDefaultModel
+        var shouldRunDeleteOnSettingsChange = true
 
         var showCarbsRequiredBadge: Bool = true
         private(set) var setupPumpType: PumpConfig.PumpType = .minimed
@@ -455,8 +456,13 @@ extension Home {
             case .plugin:
                 shouldDisplayCGMSetupSheet = true
             default:
-                fetchGlucoseManager.cgmGlucoseSourceType = cgmCurrent.type
-                completionNotifyingDidComplete(CGMSetupCompletionNotifying())
+                shouldDisplayCGMSetupSheet = true
+                settingsManager.settings.cgm = cgmCurrent.type
+                settingsManager.settings.cgmPluginIdentifier = ""
+                fetchGlucoseManager.updateGlucoseSource(cgmGlucoseSourceType: cgmCurrent.type, cgmGlucosePluginId: cgmCurrent.id)
+                broadcaster.notify(GlucoseObserver.self, on: .main) {
+                    $0.glucoseDidUpdate([])
+                }
             }
         }
 
@@ -465,12 +471,14 @@ extension Home {
                 // Call plugin functionality on the manager queue (or at least attempt to)
                 Task {
                     await self.fetchGlucoseManager?.deleteGlucoseSource()
-                }
 
-                // UI updates go back to Main
-                DispatchQueue.main.async {
-                    self.shouldDisplayCGMSetupSheet = false
-                    self.completionNotifyingDidComplete(CGMDeletionCompletionNotifying())
+                    // UI updates go back to Main
+                    await MainActor.run {
+                        self.shouldDisplayCGMSetupSheet = false
+                        self.broadcaster.notify(GlucoseObserver.self, on: .main) {
+                            $0.glucoseDidUpdate([])
+                        }
+                    }
                 }
             }
         }
@@ -643,6 +651,17 @@ extension Home.StateModel:
         Task {
             await setupCGMSettings()
         }
+        if settingsManager.settings.cgm == .none, shouldRunDeleteOnSettingsChange {
+            shouldRunDeleteOnSettingsChange = false
+            cgmCurrent = cgmDefaultModel
+            DispatchQueue.main.async {
+                self.broadcaster.notify(GlucoseObserver.self, on: .main) {
+                    $0.glucoseDidUpdate([])
+                }
+            }
+        } else {
+            shouldRunDeleteOnSettingsChange = true
+        }
     }
 
     func preferencesDidChange(_: Preferences) {
@@ -685,48 +704,6 @@ extension Home.StateModel:
     }
 }
 
-extension Home.StateModel: CompletionDelegate {
-    func completionNotifyingDidComplete(_ notifying: CompletionNotifying) {
-        debug(.service, "Completion fired by: \(type(of: notifying))")
-        shouldDisplayCGMSetupSheet = false
-
-        if notifying is CGMSetupCompletionNotifying || notifying is CGMDeletionCompletionNotifying ||
-            notifying is CGMManagerSettingsNavigationViewController || notifying is any SetupTableViewControllerDelegate ||
-            notifying is any CGMManagerOnboarding
-        {
-            if fetchGlucoseManager.cgmGlucoseSourceType == .none {
-                debug(.service, "CGMDeletionCompletionNotifying: CGM Deletion Completed")
-
-                cgmCurrent = cgmDefaultModel
-                settingsManager.settings.cgm = cgmDefaultModel.type
-                settingsManager.settings.cgmPluginIdentifier = cgmDefaultModel.id
-                Task {
-                    await fetchGlucoseManager.deleteGlucoseSource()
-                }
-            } else {
-                debug(.service, "CGMSetupCompletionNotifying: CGM Setup Completed")
-
-                settingsManager.settings.cgm = cgmCurrent.type
-                settingsManager.settings.cgmPluginIdentifier = cgmCurrent.id
-                fetchGlucoseManager.updateGlucoseSource(cgmGlucoseSourceType: cgmCurrent.type, cgmGlucosePluginId: cgmCurrent.id)
-
-                shouldDisplayCGMSetupSheet = cgmCurrent.type == .simulator || cgmCurrent.type == .nightscout || cgmCurrent
-                    .type == .xdrip || cgmCurrent.type == .enlite
-            }
-
-            // update glucose source if required
-            DispatchQueue.main.async {
-                self.broadcaster.notify(GlucoseObserver.self, on: .main) {
-                    $0.glucoseDidUpdate([])
-                }
-            }
-        } else {
-            // pump related handling
-            shouldDisplayPumpSetupSheet = false // hides sheet
-        }
-    }
-}
-
 extension Home.StateModel: PumpManagerOnboardingDelegate {
     func pumpManagerOnboarding(didCreatePumpManager pumpManager: PumpManagerUI) {
         provider.apsManager.pumpManager = pumpManager
@@ -743,18 +720,3 @@ extension Home.StateModel: PumpManagerOnboardingDelegate {
         // nothing to do
     }
 }
-
-extension Home.StateModel: CGMManagerOnboardingDelegate {
-    func cgmManagerOnboarding(didCreateCGMManager manager: LoopKitUI.CGMManagerUI) {
-        // update the glucose source
-        fetchGlucoseManager.updateGlucoseSource(
-            cgmGlucoseSourceType: cgmCurrent.type,
-            cgmGlucosePluginId: cgmCurrent.id,
-            newManager: manager
-        )
-    }
-
-    func cgmManagerOnboarding(didOnboardCGMManager _: LoopKitUI.CGMManagerUI) {
-        // nothing to do
-    }
-}

+ 6 - 6
Trio/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift

@@ -123,6 +123,9 @@ extension NightscoutConfig {
                                         }
                                     }
                                 ).buttonStyle(BorderlessButtonStyle())
+                                    .alert(isPresented: $isImportAlertPresented) {
+                                        importAlert ?? Alert(title: Text("Unknown Error"))
+                                    }
                             }.padding(.top)
                         }.padding(.vertical)
                     }.listRowBackground(Color.chart)
@@ -177,6 +180,9 @@ extension NightscoutConfig {
                                             }
                                         }
                                     ).buttonStyle(BorderlessButtonStyle())
+                                        .alert(isPresented: $isBackfillAlertPresented) {
+                                            backfillAlert ?? Alert(title: Text("Unknown Error"))
+                                        }
                                 }.padding(.top)
                             }.padding(.vertical)
                         }
@@ -206,12 +212,6 @@ extension NightscoutConfig {
             }
             .navigationBarTitle("Nightscout")
             .navigationBarTitleDisplayMode(.automatic)
-            .alert(isPresented: $isImportAlertPresented) {
-                importAlert ?? Alert(title: Text("Unknown Error"))
-            }
-            .alert(isPresented: $isBackfillAlertPresented) {
-                backfillAlert ?? Alert(title: Text("Unknown Error"))
-            }
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .onAppear(perform: configureView)
         }

+ 29 - 37
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -4,16 +4,16 @@ import SwiftUI
 
 struct SettingItem: Identifiable {
     let id = UUID()
-    let title: LocalizedStringKey
+    let title: String
     let view: Screen
-    let searchContents: [LocalizedStringKey]?
-    let path: [LocalizedStringKey]?
+    let searchContents: [String]?
+    let path: [String]?
 
     init(
-        title: LocalizedStringKey,
+        title: String,
         view: Screen,
-        searchContents: [LocalizedStringKey]? = nil,
-        path: [LocalizedStringKey]? = nil
+        searchContents: [String]? = nil,
+        path: [String]? = nil
     ) {
         self.title = title
         self.view = view
@@ -25,7 +25,7 @@ struct SettingItem: Identifiable {
 struct FilteredSettingItem: Identifiable {
     let id = UUID()
     let settingItem: SettingItem
-    let matchedContent: LocalizedStringKey
+    let matchedContent: String
 }
 
 enum SettingItems {
@@ -302,19 +302,23 @@ enum SettingItems {
     static func filteredItems(searchText: String) -> [FilteredSettingItem] {
         allItems.flatMap { item in
             var results = [FilteredSettingItem]()
-            let searchTextToLower = searchText.lowercased()
+            let searchLower = searchText.lowercased()
 
-            if item.title.stringValue.localizedCaseInsensitiveContains(searchTextToLower) ||
-                item.title.englishValue.localizedCaseInsensitiveContains(searchTextToLower)
+            let titleLocalized = item.title.localized
+            let titleEnglish = item.title.englishLocalized
+
+            if titleLocalized.localizedCaseInsensitiveContains(searchLower) ||
+                titleEnglish.localizedCaseInsensitiveContains(searchLower)
             {
                 results.append(FilteredSettingItem(settingItem: item, matchedContent: item.title))
             }
 
-            if let matchedContents = item.searchContents?.filter({
-                $0.stringValue.localizedCaseInsensitiveContains(searchTextToLower) ||
-                    $0.englishValue.localizedCaseInsensitiveContains(searchTextToLower)
-            }) {
-                results.append(contentsOf: matchedContents.map { FilteredSettingItem(settingItem: item, matchedContent: $0) })
+            if let contents = item.searchContents {
+                let matched = contents.filter {
+                    $0.localized.localizedCaseInsensitiveContains(searchLower) ||
+                        $0.englishLocalized.localizedCaseInsensitiveContains(searchLower)
+                }
+                results.append(contentsOf: matched.map { FilteredSettingItem(settingItem: item, matchedContent: $0) })
             }
 
             return results
@@ -322,29 +326,17 @@ enum SettingItems {
     }
 }
 
-extension LocalizedStringKey {
-    var stringValue: String {
-        let mirror = Mirror(reflecting: self)
-        let children = mirror.children
-        if let label = children.first(where: { $0.label == "key" })?.value as? String {
-            return String(localized: "\(label)", comment: "")
-        } else {
-            return ""
+extension String {
+    func localizedString(locale: Locale = .current) -> String {
+        if locale.identifier == "en",
+           let path = Bundle.main.path(forResource: "en", ofType: "lproj"),
+           let bundle = Bundle(path: path)
+        {
+            return NSLocalizedString(self, bundle: bundle, comment: "")
         }
+        return NSLocalizedString(self, comment: "")
     }
 
-    var englishValue: String {
-        let mirror = Mirror(reflecting: self)
-        let children = mirror.children
-
-        if let key = children.first(where: { $0.label == "key" })?.value as? String {
-            if let path = Bundle.main.path(forResource: "en", ofType: "lproj"),
-               let bundle = Bundle(path: path)
-            {
-                return bundle.localizedString(forKey: key, value: nil, table: nil)
-            }
-        }
-
-        return ""
-    }
+    var localized: String { localizedString() }
+    var englishLocalized: String { localizedString(locale: Locale(identifier: "en")) }
 }

+ 6 - 1
Trio/Sources/Modules/Settings/SettingsStateModel.swift

@@ -33,7 +33,7 @@ extension Settings {
 
             versionNumber = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
 
-            branch = BuildDetails.default.branchAndSha
+            branch = BuildDetails.shared.branchAndSha
 
             copyrightNotice = Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String ?? ""
 
@@ -74,6 +74,11 @@ extension Settings {
 //            let storageURL = localDocuments.appendingPathComponent("PumpManagerState" + ".plist")
 //            try? FileManager.default.removeItem(at: storageURL)
 //        }
+        func hasCgmAndPump() -> Bool {
+            let hasCgm = fetchCgmManager.cgmGlucoseSourceType != .none
+            let hasPump = provider.deviceManager.pumpManager != nil
+            return hasCgm && hasPump
+        }
     }
 }
 

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

@@ -29,6 +29,7 @@ extension Settings {
             isUpdateAvailable: false,
             isBlacklisted: false
         )
+        @State private var closedLoopDisabled = true
 
         @Environment(\.colorScheme) var colorScheme
         @EnvironmentObject var appIcons: Icons
@@ -72,7 +73,7 @@ extension Settings {
         var body: some View {
             List {
                 if searchText.isEmpty {
-                    let buildDetails = BuildDetails.default
+                    let buildDetails = BuildDetails.shared
 
                     Section(
                         header: Text("BRANCH: \(buildDetails.branchAndSha)").textCase(nil),
@@ -113,6 +114,11 @@ extension Settings {
                         }
                     ).listRowBackground(Color.chart)
 
+                    let miniHintText = closedLoopDisabled ?
+                        String(localized: "Add a CGM and pump to enable automated insuin delivery") :
+                        String(localized: "Enable automated insulin delivery.")
+                    let miniHintTextColorForDisabled: Color = colorScheme == .dark ? .orange : .accentColor
+                    let miniHintTextColor: Color = closedLoopDisabled ? miniHintTextColorForDisabled : .secondary
                     SettingInputSection(
                         decimalValue: $decimalPlaceholder,
                         booleanValue: $state.closedLoop,
@@ -127,7 +133,7 @@ extension Settings {
                         units: state.units,
                         type: .boolean,
                         label: String(localized: "Closed Loop"),
-                        miniHint: String(localized: "Enable automated insulin delivery."),
+                        miniHint: miniHintText,
                         verboseHint: VStack(alignment: .leading, spacing: 10) {
                             Text(
                                 "Running Trio in closed loop mode requires an active CGM sensor session and a connected pump. This enables automated insulin delivery."
@@ -136,14 +142,19 @@ extension Settings {
                                 "Before enabling, dial in your settings (basal / insulin sensitivity / carb ratio), and familiarize yourself with the app."
                             )
                         },
-                        headerText: String(localized: "Automated Insulin Delivery")
+                        headerText: String(localized: "Automated Insulin Delivery"),
+                        isToggleDisabled: closedLoopDisabled,
+                        miniHintColor: miniHintTextColor
                     )
+                    .onAppear {
+                        closedLoopDisabled = !state.hasCgmAndPump()
+                    }
 
                     Section(
                         header: Text("Trio Configuration"),
                         content: {
                             ForEach(SettingItems.trioConfig) { item in
-                                Text(item.title).navigationLink(to: item.view, from: self)
+                                Text(LocalizedStringKey(item.title)).navigationLink(to: item.view, from: self)
                             }
                         }
                     )
@@ -239,12 +250,13 @@ extension Settings {
                             if filteredItems.isNotEmpty {
                                 ForEach(filteredItems) { filteredItem in
                                     VStack(alignment: .leading) {
-                                        Text(filteredItem.matchedContent).bold()
+                                        Text(filteredItem.matchedContent.localized).bold()
                                         if let path = filteredItem.settingItem.path {
-                                            Text(path.map(\.stringValue).joined(separator: " > "))
+                                            Text(path.map(\.localized).joined(separator: " > "))
                                                 .font(.caption)
                                                 .foregroundColor(.secondary)
                                         }
+
                                     }.navigationLink(to: filteredItem.settingItem.view, from: self)
                                 }
                             } else {

+ 25 - 13
Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift

@@ -23,6 +23,16 @@ struct LoopStatsByPeriod: Identifiable {
     var id: Date { period }
 }
 
+struct LoopStatsProcessedData: Identifiable {
+    var id = UUID()
+    let category: LoopStatsDataType
+    let count: Int
+    let percentage: Double
+    let medianDuration: Double
+    let medianInterval: Double
+    let totalDays: Int
+}
+
 enum LoopStatsDataType: String {
     case successfulLoop
     case glucoseCount
@@ -142,7 +152,7 @@ extension Stat.StateModel {
         failedLoopIds: [NSManagedObjectID],
         interval: StatsTimeIntervalWithToday
     ) async throws
-        -> [(category: LoopStatsDataType, count: Int, percentage: Double, medianDuration: Double, medianInterval: Double)]
+        -> [LoopStatsProcessedData]
     {
         // Calculate the date range for glucose readings
         let now = Date()
@@ -197,19 +207,21 @@ extension Stat.StateModel {
             let glucosePercentage = (averageGlucosePerDay / maxLoopsPerDay) * 100
 
             return [
-                (
-                    LoopStatsDataType.successfulLoop,
-                    Int(round(averageLoopsPerDay)),
-                    loopPercentage,
-                    medianDuration,
-                    medianInterval
+                LoopStatsProcessedData(
+                    category: LoopStatsDataType.successfulLoop,
+                    count: Int(round(averageLoopsPerDay)),
+                    percentage: loopPercentage,
+                    medianDuration: medianDuration,
+                    medianInterval: medianInterval,
+                    totalDays: numberOfDays
                 ),
-                (
-                    LoopStatsDataType.glucoseCount,
-                    Int(round(averageGlucosePerDay)),
-                    glucosePercentage,
-                    medianDuration,
-                    medianInterval
+                LoopStatsProcessedData(
+                    category: LoopStatsDataType.glucoseCount,
+                    count: Int(round(averageGlucosePerDay)),
+                    percentage: glucosePercentage,
+                    medianDuration: medianDuration,
+                    medianInterval: medianInterval,
+                    totalDays: numberOfDays
                 )
             ]
         }

+ 9 - 13
Trio/Sources/Modules/Stat/StatStateModel.swift

@@ -10,18 +10,11 @@ extension Stat {
         var highLimit: Decimal = 180
         var lowLimit: Decimal = 70
         var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
-        var timeInRangeChartStyle: TimeInRangeChartStyle = .vertical
         var units: GlucoseUnits = .mgdL
         var useFPUconversion: Bool = false
         var glucoseFromPersistence: [GlucoseStored] = []
         var loopStatRecords: [LoopStatRecord] = []
-        var loopStats: [(
-            category: LoopStatsDataType,
-            count: Int,
-            percentage: Double,
-            medianDuration: Double,
-            medianInterval: Double
-        )] = []
+        var loopStats: [LoopStatsProcessedData] = []
         var groupedLoopStats: [LoopStatsByPeriod] = []
         var bolusStats: [BolusStats] = []
         var hourlyStats: [HourlyStats] = []
@@ -91,7 +84,6 @@ extension Stat {
             setupMealStats()
             units = settingsManager.settings.units
             eA1cDisplayUnit = settingsManager.settings.eA1cDisplayUnit
-            timeInRangeChartStyle = settingsManager.settings.timeInRangeChartStyle
             useFPUconversion = settingsManager.settings.useFPUconversion
         }
 
@@ -327,10 +319,14 @@ extension Stat.StateModel {
 
         var displayName: String {
             switch self {
-            case .glucose: return "Glucose"
-            case .insulin: return "Insulin"
-            case .looping: return "Looping"
-            case .meals: return "Meals"
+            case .glucose:
+                return String(localized: "Glucose", comment: "Title for glucose-related statistics")
+            case .insulin:
+                return String(localized: "Insulin", comment: "Title for insulin-related statistics")
+            case .looping:
+                return String(localized: "Looping", comment: "Title for looping and system statistics")
+            case .meals:
+                return String(localized: "Meals", comment: "Title for meal-related statistics")
             }
         }
     }

+ 2 - 2
Trio/Sources/Modules/Stat/View/StatRootView.swift

@@ -176,7 +176,7 @@ extension Stat {
 
             Picker("Duration", selection: $state.selectedIntervalForInsulinStats) {
                 ForEach(StateModel.StatsTimeInterval.allCases) { timeInterval in
-                    Text(timeInterval.rawValue).tag(timeInterval)
+                    Text(timeInterval.displayName).tag(timeInterval)
                 }
             }
             .pickerStyle(.segmented)
@@ -316,7 +316,7 @@ extension Stat {
 
             Picker("Duration", selection: $state.selectedIntervalForMealStats) {
                 ForEach(StateModel.StatsTimeInterval.allCases, id: \.self) { timeInterval in
-                    Text(timeInterval.rawValue)
+                    Text(timeInterval.displayName)
                 }
             }
             .pickerStyle(.segmented)

+ 20 - 12
Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseMetricsView.swift

@@ -27,11 +27,13 @@ struct GlucoseMetricsView: View {
         let totalDays = (latestDate - earliestDate).timeInterval / 86400
 
         // Format glucose statistics based on the selected unit
-        let eA1cString = preferredUnit == .mmolL
-            ? glucoseStats.ifcc.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
-            : glucoseStats.ngsp.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%"
+        let eA1cString = preferredUnit == .mgdL
+            ? (glucoseStats.ngsp.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%") : glucoseStats
+            .ifcc.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
 
-        let gmiString = glucoseStats.gmi.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%"
+        let gmiString = preferredUnit == .mgdL ?
+            (glucoseStats.gmiPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%") :
+            glucoseStats.gmiMmolMol.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
 
         // glucoseStats already parsed to units - only format decimals
         let standardDeviationString = units == .mgdL ? glucoseStats.sd.formatted(
@@ -40,7 +42,7 @@ struct GlucoseMetricsView: View {
             .number.grouping(.never).rounded().precision(.fractionLength(1))
         )
         let coefficientOfVariationString = glucoseStats.cv
-            .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0)))
+            .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%"
         let daysTrackedString = totalDays.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
 
         VStack(alignment: .leading) {
@@ -60,14 +62,14 @@ struct GlucoseMetricsView: View {
 
     /// Computes various statistical metrics from stored glucose readings, including:
     /// - Estimated A1c in NGSP (%) and IFCC (mmol/mol)
-    /// - Glucose Management Index (GMI)
+    /// - Glucose Management Index (GMI) in both mmol/mol and percentage
     /// - Average and median glucose levels
     /// - Standard deviation (SD) and coefficient of variation (CV)
     /// - Number of readings per day
     ///
     /// - Returns: A tuple containing glucose statistics.
     func calculateGlucoseStatistics() -> (
-        ifcc: Double, ngsp: Double, gmi: Double, average: Double,
+        ifcc: Double, ngsp: Double, gmiMmolMol: Double, gmiPercentage: Double, average: Double,
         median: Double, sd: Double, cv: Double, readingsPerDay: Double
     ) {
         // Determine the date range of the glucose data
@@ -84,7 +86,7 @@ struct GlucoseMetricsView: View {
 
         // Handle empty dataset case
         guard totalReadings > 1 else {
-            return (ifcc: 0, ngsp: 0, gmi: 0, average: 0, median: 0, sd: 0, cv: 0, readingsPerDay: 0)
+            return (ifcc: 0, ngsp: 0, gmiMmolMol: 0, gmiPercentage: 0, average: 0, median: 0, sd: 0, cv: 0, readingsPerDay: 0)
         }
 
         let sumOfReadings = glucoseValues.reduce(0, +)
@@ -96,7 +98,8 @@ struct GlucoseMetricsView: View {
         // Estimated A1c and Glucose Management Index (GMI) calculations
         var eA1cNGSP = 0.0 // eA1c NGSP (%)
         var eA1cIFCC = 0.0 // eA1c IFCC (mmol/mol)
-        var gmiValue = 0.0 // Glucose Management Index (GMI)
+        var gmiValuePercentage = 0.0 // GMI (%)
+        var gmiValueMmolMol = 0.0 // GMI (mmol/mol)
 
         if totalDays > 0 {
             // **eA1c NGSP Calculation** (CGM-based)
@@ -107,9 +110,13 @@ struct GlucoseMetricsView: View {
             // eA1c IFCC (mmol/mol) = 10.929 * (eA1c NGSP - 2.152)
             eA1cIFCC = 10.929 * (eA1cNGSP - 2.152)
 
-            // **Glucose Management Index (GMI)**
+            // **Glucose Management Index (GMI) in %**
             // GMI = 3.31 + (0.02392 × Average Glucose mg/dL)
-            gmiValue = 3.31 + (0.02392 * meanGlucose)
+            gmiValuePercentage = 3.31 + (0.02392 * meanGlucose)
+
+            // **Glucose Management Index (GMI) in mmol/mol**
+            // GMI mmol/mol = (GMI % - 2.15) * 10.929
+            gmiValueMmolMol = (gmiValuePercentage - 2.152) * 10.929
         }
 
         // Compute Standard Deviation (SD) and Coefficient of Variation (CV)
@@ -123,7 +130,8 @@ struct GlucoseMetricsView: View {
         return (
             ifcc: eA1cIFCC, // eA1c in IFCC (mmol/mol)
             ngsp: eA1cNGSP, // eA1c in NGSP (%)
-            gmi: gmiValue, // Glucose Management Index
+            gmiMmolMol: gmiValueMmolMol, // GMI in mmol/mol
+            gmiPercentage: gmiValuePercentage, // GMI in %
             average: Double(units == .mgdL ? Decimal(meanGlucose) : meanGlucose.asMmolL),
             median: Double(units == .mgdL ? Decimal(medianGlucose) : medianGlucose.asMmolL),
             sd: Double(units == .mgdL ? Decimal(standardDeviation) : standardDeviation.asMmolL),

+ 2 - 8
Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopBarChartView.swift

@@ -4,7 +4,7 @@ import SwiftUI
 struct LoopBarChartView: View {
     let loopStatRecords: [LoopStatRecord]
     let selectedInterval: Stat.StateModel.StatsTimeIntervalWithToday
-    let statsData: [(category: LoopStatsDataType, count: Int, percentage: Double, medianDuration: Double, medianInterval: Double)]
+    let statsData: [LoopStatsProcessedData]
 
     var body: some View {
         VStack(spacing: 20) {
@@ -50,13 +50,7 @@ struct LoopBarChartView: View {
         }
     }
 
-    private func annotationText(for data: (
-        category: LoopStatsDataType,
-        count: Int,
-        percentage: Double,
-        medianDuration: Double,
-        medianInterval: Double
-    )) -> String {
+    private func annotationText(for data: LoopStatsProcessedData) -> String {
         if data.category == .successfulLoop {
             switch selectedInterval {
             case .day,

+ 6 - 1
Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopStatsView.swift

@@ -4,7 +4,7 @@ import SwiftUI
 /// A SwiftUI view displaying statistics about the looping process in an Automated Insulin Delivery (AID) system.
 struct LoopStatsView: View {
     /// The list of loop statistics records used to generate the statistics.
-    let statsData: [(category: LoopStatsDataType, count: Int, percentage: Double, medianDuration: Double, medianInterval: Double)]
+    let statsData: [LoopStatsProcessedData]
 
     /// The main body of the `LoopStatsView`, displaying loop statistics.
     var body: some View {
@@ -32,6 +32,11 @@ struct LoopStatsView: View {
                     value: (successfulStats.percentage / 100)
                         .formatted(.percent.grouping(.never).rounded().precision(.fractionLength(1)))
                 )
+                Spacer()
+                StatChartUtils.statView(
+                    title: String(localized: "Days"),
+                    value: successfulStats.totalDays.description
+                )
             }
             .padding()
         }

+ 0 - 58
Trio/Sources/Modules/StatConfig/StatConfigStateModel.swift

@@ -1,58 +0,0 @@
-import SwiftUI
-
-extension StatConfig {
-    final class StateModel: BaseStateModel<Provider> {
-        @Published var overrideHbA1cUnit = false
-
-        @Published var skipBolusScreenAfterCarbs: Bool = false
-        @Published var useFPUconversion: Bool = true
-        @Published var tins: Bool = false
-        @Published var historyLayout: HistoryLayout = .twoTabs
-        @Published var lockScreenView: LockScreenView = .simple
-        @Published var low: Decimal = 70
-        @Published var high: Decimal = 180
-        @Published var hours: Decimal = 6
-        @Published var dynamicGlucoseColor = false
-        @Published var xGridLines = false
-        @Published var yGridLines: Bool = false
-        @Published var oneDimensionalGraph = false
-        @Published var rulerMarks: Bool = true
-        @Published var displayForecastsAsLines: Bool = false
-
-        var units: GlucoseUnits = .mgdL
-
-        override func subscribe() {
-            let units = settingsManager.settings.units
-            self.units = units
-
-            subscribeSetting(\.overrideHbA1cUnit, on: $overrideHbA1cUnit) { overrideHbA1cUnit = $0 }
-            subscribeSetting(\.dynamicGlucoseColor, on: $dynamicGlucoseColor) { dynamicGlucoseColor = $0 }
-            subscribeSetting(\.xGridLines, on: $xGridLines) { xGridLines = $0 }
-            subscribeSetting(\.yGridLines, on: $yGridLines) { yGridLines = $0 }
-            subscribeSetting(\.rulerMarks, on: $rulerMarks) { rulerMarks = $0 }
-            subscribeSetting(\.displayForecastsAsLines, on: $displayForecastsAsLines) { displayForecastsAsLines = $0 }
-            subscribeSetting(\.useFPUconversion, on: $useFPUconversion) { useFPUconversion = $0 }
-            subscribeSetting(\.tins, on: $tins) { tins = $0 }
-            subscribeSetting(\.skipBolusScreenAfterCarbs, on: $skipBolusScreenAfterCarbs) { skipBolusScreenAfterCarbs = $0 }
-            subscribeSetting(\.oneDimensionalGraph, on: $oneDimensionalGraph) { oneDimensionalGraph = $0 }
-            subscribeSetting(\.historyLayout, on: $historyLayout) { historyLayout = $0 }
-            subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
-
-            subscribeSetting(\.low, on: $low, initial: {
-                let value = max(min($0, 90), 40)
-                low = units == .mmolL ? value.asMmolL : value
-            }, map: {
-                guard units == .mmolL else { return $0 }
-                return $0.asMgdL
-            })
-
-            subscribeSetting(\.high, on: $high, initial: {
-                let value = max(min($0, 270), 110)
-                high = units == .mmolL ? value.asMmolL : value
-            }, map: {
-                guard units == .mmolL else { return $0 }
-                return $0.asMgdL
-            })
-        }
-    }
-}

+ 0 - 113
Trio/Sources/Modules/StatConfig/View/StatConfigRootView.swift

@@ -1,113 +0,0 @@
-import SwiftUI
-import Swinject
-
-extension StatConfig {
-    struct RootView: BaseView {
-        let resolver: Resolver
-        @StateObject var state = StateModel()
-
-        @Environment(\.colorScheme) var colorScheme
-        var color: LinearGradient {
-            colorScheme == .dark ? LinearGradient(
-                gradient: Gradient(colors: [
-                    Color.bgDarkBlue,
-                    Color.bgDarkerDarkBlue
-                ]),
-                startPoint: .top,
-                endPoint: .bottom
-            )
-                :
-                LinearGradient(
-                    gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
-                    startPoint: .top,
-                    endPoint: .bottom
-                )
-        }
-
-        private var glucoseFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            if state.units == .mmolL {
-                formatter.maximumFractionDigits = 1
-            }
-            formatter.roundingMode = .halfUp
-            return formatter
-        }
-
-        private var carbsFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            return formatter
-        }
-
-        var body: some View {
-            Form {
-                Section {
-                    Toggle("Use Dynamic BG Color", isOn: $state.dynamicGlucoseColor)
-                    Toggle("Display Chart X - Grid lines", isOn: $state.xGridLines)
-                    Toggle("Display Chart Y - Grid lines", isOn: $state.yGridLines)
-                    Toggle("Display Chart Threshold lines for Low and High", isOn: $state.rulerMarks)
-                    Toggle("Standing / Laying TIR Chart", isOn: $state.oneDimensionalGraph)
-                    Toggle("Enable total insulin in scope", isOn: $state.tins)
-                    HStack {
-                        Text("Hours X-Axis (6 default)")
-                        Spacer()
-                        TextFieldWithToolBar(text: $state.hours, placeholder: "6", numberFormatter: carbsFormatter)
-                        Text("hours").foregroundColor(.secondary)
-                    }
-                    Toggle("Show Forecasts as Lines", isOn: $state.displayForecastsAsLines)
-                } header: { Text("Home Chart settings ") }
-
-                Section {
-                    HStack {
-                        Text("Low")
-                        Spacer()
-                        TextFieldWithToolBar(text: $state.low, placeholder: "0", numberFormatter: glucoseFormatter)
-                        Text(state.units.rawValue).foregroundColor(.secondary)
-                    }
-                    HStack {
-                        Text("High")
-                        Spacer()
-                        TextFieldWithToolBar(text: $state.high, placeholder: "0", numberFormatter: glucoseFormatter)
-                        Text(state.units.rawValue).foregroundColor(.secondary)
-                    }
-                    Toggle("Override HbA1c Unit", isOn: $state.overrideHbA1cUnit)
-
-                } header: { Text("Statistics settings ") }
-
-                Section {
-                    Toggle("Skip Bolus screen after carbs", isOn: $state.skipBolusScreenAfterCarbs)
-                    Toggle("Display and allow Fat and Protein entries", isOn: $state.useFPUconversion)
-                } header: { Text("Add Meal View settings ") }
-
-                Section {
-                    Picker(
-                        selection: $state.historyLayout,
-                        label: Text("History Layout")
-                    ) {
-                        ForEach(HistoryLayout.allCases) { selection in
-                            Text(selection.displayName).tag(selection)
-                        }
-                    }
-                } header: { Text("History Settings") }
-
-                Section {
-                    Picker(
-                        selection: $state.lockScreenView,
-                        label: Text("Lock screen widget")
-                    ) {
-                        ForEach(LockScreenView.allCases) { selection in
-                            Text(selection.displayName).tag(selection)
-                        }
-                    }
-                } header: { Text("Lock screen widget") }
-            }
-            .scrollContentBackground(.hidden).background(color)
-            .onAppear(perform: configureView)
-            .navigationBarTitle("UI/UX")
-            .navigationBarTitleDisplayMode(.automatic)
-        }
-    }
-}

+ 4 - 2
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -157,8 +157,10 @@ extension Treatments {
 
         deinit {
             // Unregister from broadcaster
-            broadcaster.unregister(DeterminationObserver.self, observer: self)
-            broadcaster.unregister(BolusFailureObserver.self, observer: self)
+            if let broadcaster = broadcaster {
+                broadcaster.unregister(DeterminationObserver.self, observer: self)
+                broadcaster.unregister(BolusFailureObserver.self, observer: self)
+            }
 
             // Cancel Combine subscriptions
             unsubscribe()

+ 16 - 10
Trio/Sources/Modules/Treatments/View/ForecastChart.swift

@@ -83,16 +83,9 @@ struct ForecastChart: View {
                 Image(systemName: "arrow.right.circle")
 
                 if let simulatedDetermination = state.simulatedDetermination, let eventualBG = simulatedDetermination.eventualBG {
-                    HStack {
-                        Text(
-                            state.units == .mgdL ? Decimal(eventualBG).description : eventualBG.formattedAsMmolL
-                        )
-                        .font(.footnote)
-                        .foregroundStyle(.primary)
-                        Text("\(state.units.rawValue)")
-                            .font(.footnote)
-                            .foregroundStyle(.secondary)
-                    }
+                    eventualGlucoseBadge(for: eventualBG)
+                } else if let lastDetermination = state.determination.first, let eventualBG = lastDetermination.eventualBG {
+                    eventualGlucoseBadge(for: Int(truncating: eventualBG))
                 } else {
                     Text("---")
                         .font(.footnote)
@@ -112,6 +105,19 @@ struct ForecastChart: View {
         }
     }
 
+    @ViewBuilder private func eventualGlucoseBadge(for eventualBG: Int) -> some View {
+        HStack {
+            Text(
+                state.units == .mgdL ? Decimal(eventualBG).description : eventualBG.formattedAsMmolL
+            )
+            .font(.footnote)
+            .foregroundStyle(.primary)
+            Text("\(state.units.rawValue)")
+                .font(.footnote)
+                .foregroundStyle(.secondary)
+        }
+    }
+
     private var forecastChart: some View {
         Chart {
             drawGlucose()

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

@@ -347,7 +347,7 @@ extension Treatments {
                 .blur(radius: state.isAwaitingDeterminationResult ? 5 : 0)
 
                 if state.isAwaitingDeterminationResult {
-                    CustomProgressView(text: progressText.rawValue)
+                    CustomProgressView(text: progressText.displayName)
                 }
             }
             .padding(.top)

+ 0 - 3
Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift

@@ -12,7 +12,6 @@ extension UserInterfaceSettings {
         @Published var carbsRequiredThreshold: Decimal = 0
         @Published var glucoseColorScheme: GlucoseColorScheme = .staticColor
         @Published var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
-        @Published var timeInRangeChartStyle: TimeInRangeChartStyle = .vertical
 
         var units: GlucoseUnits = .mgdL
 
@@ -40,8 +39,6 @@ extension UserInterfaceSettings {
             subscribeSetting(\.glucoseColorScheme, on: $glucoseColorScheme) { glucoseColorScheme = $0 }
 
             subscribeSetting(\.eA1cDisplayUnit, on: $eA1cDisplayUnit) { eA1cDisplayUnit = $0 }
-
-            subscribeSetting(\.timeInRangeChartStyle, on: $timeInRangeChartStyle) { timeInRangeChartStyle = $0 }
         }
     }
 }

+ 5 - 45
Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift

@@ -286,7 +286,7 @@ extension UserInterfaceSettings {
 
                             HStack(alignment: .center) {
                                 Text(
-                                    "Set low and high glucose values for the main screen glucose graph and statistics."
+                                    "Set low and high glucose values for the main screen, watch app and live activity glucose graph."
                                 )
                                 .lineLimit(nil)
                                 .font(.footnote)
@@ -382,7 +382,7 @@ extension UserInterfaceSettings {
                         VStack {
                             Picker(
                                 selection: $state.eA1cDisplayUnit,
-                                label: Text("eA1c Display Unit")
+                                label: Text("eA1c/GMI Display Unit")
                             ) {
                                 ForEach(EstimatedA1cDisplayUnit.allCases) { selection in
                                     Text(selection.displayName).tag(selection)
@@ -391,7 +391,7 @@ extension UserInterfaceSettings {
 
                             HStack(alignment: .center) {
                                 Text(
-                                    "Choose to display eA1c in percent or mmol/mol."
+                                    "Choose to display eA1c and GMI in percent or mmol/mol."
                                 )
                                 .font(.footnote)
                                 .foregroundColor(.secondary)
@@ -399,11 +399,11 @@ extension UserInterfaceSettings {
                                 Spacer()
                                 Button(
                                     action: {
-                                        hintLabel = String(localized: "eA1c Display Unit")
+                                        hintLabel = String(localized: "eA1c/GMI Display Unit")
                                         selectedVerboseHint =
                                             AnyView(
                                                 Text(
-                                                    "Choose which format you'd prefer the eA1c (estimated A1c) value in the statistics view as a percentage (Example: 6.5%) or mmol/mol (Example: 48 mmol/mol)."
+                                                    "Choose which format you'd prefer the eA1c (estimated A1c) and GMI (Glucose Management Index) value in the statistics view as a percentage (Example: eA1c: 6.5%) or mmol/mol (Example: eA1c: 48 mmol/mol)."
                                                 )
                                             )
                                         shouldDisplayHint.toggle()
@@ -419,46 +419,6 @@ extension UserInterfaceSettings {
                     }
                 ).listRowBackground(Color.chart)
 
-                Section {
-                    VStack(alignment: .leading) {
-                        Picker(
-                            selection: $state.timeInRangeChartStyle,
-                            label: Text("Time in Range Chart Style").multilineTextAlignment(.leading)
-                        ) {
-                            ForEach(TimeInRangeChartStyle.allCases) { selection in
-                                Text(selection.displayName).tag(selection)
-                            }
-                        }.padding(.top)
-
-                        HStack(alignment: .center) {
-                            Text(
-                                "Choose the orientation of the Time in Range Chart."
-                            )
-                            .font(.footnote)
-                            .foregroundColor(.secondary)
-                            .lineLimit(nil)
-                            Spacer()
-                            Button(
-                                action: {
-                                    hintLabel = String(localized: "Time in Range Chart Style")
-                                    selectedVerboseHint =
-                                        AnyView(
-                                            Text(
-                                                "Choose which style for the time in range chart you'd prefer: a standing, i.e., vertical, bar chart or a laying, i.e., horizontal, line chart."
-                                            )
-                                        )
-                                    shouldDisplayHint.toggle()
-                                },
-                                label: {
-                                    HStack {
-                                        Image(systemName: "questionmark.circle")
-                                    }
-                                }
-                            ).buttonStyle(BorderlessButtonStyle())
-                        }.padding(.top)
-                    }.padding(.bottom)
-                }.listRowBackground(Color.chart)
-
                 SettingInputSection(
                     decimalValue: $state.carbsRequiredThreshold,
                     booleanValue: $state.showCarbsRequiredBadge,

+ 76 - 11
Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift

@@ -504,6 +504,19 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             return
         }
 
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: TDDStored.self,
+            onContext: backgroundContext,
+            predicate: NSPredicate.predicateFor30MinAgo,
+            key: "date",
+            ascending: false,
+            fetchLimit: 1
+        )
+
+        let tdd: Decimal? = await backgroundContext.perform {
+            (results as? [TDDStored])?.first?.total as? Decimal
+        }
+
         // Suggested / Enacted
         async let enactedDeterminationID = determinationStorage
             .fetchLastDeterminationObjectID(predicate: NSPredicate.enactedDeterminationsNotYetUploadedToNightscout)
@@ -543,6 +556,10 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                 suggestion.minPredBG = suggestion.minPredBG?.asMmolL
                 suggestion.threshold = suggestion.threshold?.asMmolL
             }
+
+            suggestion.reason = injectTDD(into: suggestion.reason, tdd: tdd)
+            suggestion.tdd = tdd
+
             // Check whether the last suggestion that was uploaded is the same that is fetched again when we are attempting to upload the enacted determination
             // Apparently we are too fast; so the flag update is not fast enough to have the predicate filter last suggestion out
             // If this check is truthy, set suggestion to nil so it's not uploaded again
@@ -553,17 +570,20 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
             }
         }
 
-        if let fetchedEnacted = fetchedEnactedDetermination, settingsManager.settings.units == .mmolL {
-            var modifiedFetchedEnactedDetermination = fetchedEnactedDetermination
-            modifiedFetchedEnactedDetermination?
-                .reason = parseReasonGlucoseValuesToMmolL(fetchedEnacted.reason)
-            // TODO: verify that these parsings are needed for 3rd party apps, e.g., LoopFollow
-            modifiedFetchedEnactedDetermination?.current_target = fetchedEnacted.current_target?.asMmolL
-            modifiedFetchedEnactedDetermination?.minGuardBG = fetchedEnacted.minGuardBG?.asMmolL
-            modifiedFetchedEnactedDetermination?.minPredBG = fetchedEnacted.minPredBG?.asMmolL
-            modifiedFetchedEnactedDetermination?.threshold = fetchedEnacted.threshold?.asMmolL
+        if var enacted = fetchedEnactedDetermination {
+            if settingsManager.settings.units == .mmolL {
+                enacted.reason = parseReasonGlucoseValuesToMmolL(enacted.reason)
+                // TODO: verify that these parsings are needed for 3rd party apps, e.g., LoopFollow
+                enacted.current_target = enacted.current_target?.asMmolL
+                enacted.minGuardBG = enacted.minGuardBG?.asMmolL
+                enacted.minPredBG = enacted.minPredBG?.asMmolL
+                enacted.threshold = enacted.threshold?.asMmolL
+            }
+
+            enacted.reason = injectTDD(into: enacted.reason, tdd: tdd)
+            enacted.tdd = tdd
 
-            fetchedEnactedDetermination = modifiedFetchedEnactedDetermination
+            fetchedEnactedDetermination = enacted
         }
 
         // Gather all relevant data for OpenAPS Status
@@ -786,6 +806,7 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                 let isAPNSProduction = UserDefaults.standard.bool(forKey: "isAPNSProduction")
                 let presetOverrides = try await overridesStorage.getPresetOverridesForNightscout()
                 let teamID = Bundle.main.object(forInfoDictionaryKey: "TeamID") as? String ?? ""
+                let expireDate = BuildDetails.shared.calculateExpirationDate()
 
                 let profileStore = NightscoutProfileStore(
                     defaultProfile: defaultProfile,
@@ -798,7 +819,8 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                     deviceToken: deviceToken,
                     isAPNSProduction: isAPNSProduction,
                     overridePresets: presetOverrides,
-                    teamID: teamID
+                    teamID: teamID,
+                    expirationDate: expireDate
                 )
 
                 guard let nightscout = nightscoutAPI, isNetworkReachable else {
@@ -810,6 +832,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
                 }
 
                 try await nightscout.uploadProfile(profileStore)
+
+                BuildDetails.shared.recordUploadedExpireDate(expireDate: expireDate)
+
                 debug(.nightscout, "Profile uploaded")
             } catch {
                 debug(.nightscout, "NightscoutManager uploadProfile: \(error.localizedDescription)")
@@ -1448,3 +1473,43 @@ extension BaseNightscoutManager {
         return updatedReason
     }
 }
+
+extension BaseNightscoutManager {
+    /// Injects TDD into the provided `reason` string if TDD is available.
+    ///
+    /// - Parameters:
+    ///   - reason: The raw reason string (e.g., "minPredBG=5.2, IOBpredBG=102").
+    ///   - tdd: The total daily dose of insulin.
+    /// - Returns: A modified reason string that includes "TDD: x U" appended
+    ///   after the last matched prediction term, or at the end if no match is found.
+    func injectTDD(into reason: String, tdd: Decimal?) -> String {
+        guard let tdd = tdd else { return reason }
+
+        let tddString = ", TDD: \(tdd) U"
+
+        // Regex that matches any of the keywords followed by an optional colon, whitespace, then a number.
+        let pattern = "(minPredBG|minGuardBG|IOBpredBG|COBpredBG|UAMpredBG):?\\s*(-?\\d+(?:\\.\\d+)?)"
+        guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
+            return reason + tddString
+        }
+
+        // Split the reason at the first semicolon (if present)
+        let components = reason.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false)
+        let mainPart = String(components[0])
+        let tailPart = components.count > 1 ? ";" + components[1] : ""
+
+        // Search only in the main part for the keywords
+        let nsRange = NSRange(mainPart.startIndex ..< mainPart.endIndex, in: mainPart)
+        let matches = regex.matches(in: mainPart, options: [], range: nsRange)
+
+        // If found, insert TDD after the last occurrence in the main part.
+        if let lastMatch = matches.last, let matchRange = Range(lastMatch.range, in: mainPart) {
+            var modifiedMainPart = mainPart
+            modifiedMainPart.insert(contentsOf: tddString, at: matchRange.upperBound)
+            return modifiedMainPart + tailPart
+        }
+
+        // If no match is found, append TDD at the end of the original reason string.
+        return reason + tddString
+    }
+}

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

@@ -28,6 +28,6 @@ extension TrioRemoteControl {
     }
 
     private func isRunningInAPNSProductionEnvironment() -> Bool {
-        BuildDetails.default.isTestFlightBuild()
+        BuildDetails.shared.isTestFlightBuild()
     }
 }

+ 2 - 2
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift

@@ -45,8 +45,8 @@ extension TrioRemoteControl {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: CarbEntryStored.self,
             onContext: taskContext,
-            predicate: NSPredicate(format: "createdAt > %@", pushMessageDate as NSDate),
-            key: "createdAt",
+            predicate: NSPredicate(format: "date > %@", pushMessageDate as NSDate),
+            key: "date",
             ascending: false
         )
 

+ 18 - 16
Trio/Sources/Shortcuts/Bolus/BolusIntent.swift

@@ -5,38 +5,39 @@ import Swinject
 
 @available(iOS 16.0,*) struct BolusIntent: AppIntent {
     // Title of the action in the Shortcuts app
-    static var title = LocalizedStringResource("Enact Bolus", table: "ShortcutsDetail")
+    static var title = LocalizedStringResource("Enact Bolus")
 
     // Description of the action in the Shortcuts app
-    static var description = IntentDescription(.init("Allow to send a bolus to the app", table: "ShortcutsDetail"))
+    static var description = IntentDescription(.init("Allow to send a bolus to the app"))
 
     @Parameter(
-        title: LocalizedStringResource("Amount", table: "ShortcutsDetail"),
-        description: LocalizedStringResource("Bolus amount in U", table: "ShortcutsDetail"),
+        title: LocalizedStringResource("Amount"),
+        description: LocalizedStringResource("Bolus amount in U"),
         controlStyle: .field,
         /// The 200 upperBound does nothing here, the true max is set based on pump max
         /// An upperBound is specificed so that we can usethe lowerBound of 0, which ensures no negatives are allowed
         /// A preferred approach would be to just block negatives and not specify an upperBound here, since it is implemented elsewhere
         inclusiveRange: (lowerBound: 0, upperBound: 200),
-        requestValueDialog: IntentDialog(LocalizedStringResource(
-            "Bolus amount (units of insulin)?",
-            table: "ShortcutsDetail"
-        ))
+        requestValueDialog: IntentDialog(
+            LocalizedStringResource(
+                "Bolus amount (units of insulin)?"
+            )
+        )
     ) var bolusQuantity: Double
 
     @Parameter(
-        title: LocalizedStringResource("Confirm Before applying", table: "ShortcutsDetail"),
-        description: LocalizedStringResource("If toggled, you will need to confirm before applying.", table: "ShortcutsDetail"),
+        title: LocalizedStringResource("Confirm Before applying"),
+        description: LocalizedStringResource("If toggled, you will need to confirm before applying."),
         default: true
     ) var confirmBeforeApplying: Bool
 
     static var parameterSummary: some ParameterSummary {
         When(\.$confirmBeforeApplying, .equalTo, true, {
-            Summary("Applying \(\.$bolusQuantity) U", table: "ShortcutsDetail") {
+            Summary("Applying \(\.$bolusQuantity) U") {
                 \.$confirmBeforeApplying
             }
         }, otherwise: {
-            Summary("Immediately applying \(\.$bolusQuantity) U", table: "ShortcutsDetail") {
+            Summary("Immediately applying \(\.$bolusQuantity) U") {
                 \.$confirmBeforeApplying
             }
         })
@@ -50,10 +51,11 @@ import Swinject
             if confirmBeforeApplying {
                 try await requestConfirmation(
                     result: .result(
-                        dialog: IntentDialog(LocalizedStringResource(
-                            "Are you sure you want to bolus \(bolusFormatted) U of insulin?",
-                            table: "ShortcutsDetail"
-                        ))
+                        dialog: IntentDialog(
+                            LocalizedStringResource(
+                                "Are you sure you want to bolus \(bolusFormatted) U of insulin?"
+                            )
+                        )
                     )
                 )
             }

+ 3 - 6
Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift

@@ -9,24 +9,21 @@ import Foundation
         // Block boluses if they are disabled
         case .notAllowed:
             return LocalizedStringResource(
-                "Bolusing via Shortcuts is disabled in Trio settings.",
-                table: "ShortcutsDetail"
+                "Bolusing via Shortcuts is disabled in Trio settings."
             )
 
         // Block any bolus attempted if it is larger than the max bolus in settings
         case .limitBolusMax:
             if Decimal(bolusAmount) > settingsManager.pumpSettings.maxBolus {
                 return LocalizedStringResource(
-                    "The bolus cannot be larger than the pump setting max bolus (\(settingsManager.pumpSettings.maxBolus.description)).",
-                    table: "ShortcutsDetail"
+                    "The bolus cannot be larger than the pump setting max bolus (\(settingsManager.pumpSettings.maxBolus.description))."
                 )
             } else {
                 bolusQuantity = apsManager.roundBolus(amount: Decimal(bolusAmount))
             }
             await apsManager.enactBolus(amount: Double(bolusQuantity), isSMB: false, callback: nil)
             return LocalizedStringResource(
-                "A bolus command of \(bolusQuantity.formatted()) U of insulin was sent.",
-                table: "ShortcutsDetail"
+                "A bolus command of \(bolusQuantity.formatted()) U of insulin was sent."
             )
         }
     }

+ 2 - 2
Trio/Sources/Shortcuts/LiveActivity/RestartLiveActivityIntent.swift

@@ -6,10 +6,10 @@ import Foundation
 /// dependencies injected via Swinject, and calls the restart functionality.
 @available(iOS 16.2, *) struct RestartLiveActivityIntent: LiveActivityIntent {
     /// Title of the action in the Shortcuts app.
-    static var title = LocalizedStringResource("Restart Live Activity", table: "ShortcutsDetail")
+    static var title = LocalizedStringResource("Restart Live Activity")
 
     /// Description of the action in the Shortcuts app.
-    static var description = IntentDescription(.init("Restarts Trio's Live Activity", table: "ShortcutsDetail"))
+    static var description = IntentDescription(.init("Restarts Trio's Live Activity"))
 
     /// Performs the intent by triggering the live activity restart.
     ///

+ 16 - 17
Trio/Sources/Shortcuts/Override/ApplyOverridePresetIntent.swift

@@ -4,32 +4,32 @@ import Foundation
 /// An App Intent that allows users to activate an override preset through the Shortcuts app.
 struct ApplyOverridePresetIntent: AppIntent {
     /// The title displayed for this action in the Shortcuts app.
-    static var title = LocalizedStringResource("Activate an override", table: "ShortcutsDetail")
+    static var title = LocalizedStringResource("Activate an override")
 
     /// The description displayed for this action in the Shortcuts app.
-    static var description = IntentDescription(.init("Activate an override", table: "ShortcutsDetail"))
+    static var description = IntentDescription(.init("Activate an override"))
 
     /// The override preset to be applied.
     @Parameter(
-        title: LocalizedStringResource("Override", table: "ShortcutsDetail"),
-        description: LocalizedStringResource("Override choice", table: "ShortcutsDetail")
+        title: LocalizedStringResource("Override"),
+        description: LocalizedStringResource("Override choice")
     ) var preset: OverridePreset?
 
     /// A boolean parameter that determines whether confirmation is required before applying the override.
     @Parameter(
-        title: LocalizedStringResource("Confirm Before applying", table: "ShortcutsDetail"),
-        description: LocalizedStringResource("If toggled, you will need to confirm before applying", table: "ShortcutsDetail"),
+        title: LocalizedStringResource("Confirm Before applying"),
+        description: LocalizedStringResource("If toggled, you will need to confirm before applying"),
         default: true
     ) var confirmBeforeApplying: Bool
 
     /// Defines the summary format shown in the Shortcuts app when configuring this intent.
     static var parameterSummary: some ParameterSummary {
         When(\ApplyOverridePresetIntent.$confirmBeforeApplying, .equalTo, true, {
-            Summary("Applying \(\.$preset) override", table: "ShortcutsDetail") {
+            Summary("Applying \(\.$preset) override") {
                 \.$confirmBeforeApplying
             }
         }, otherwise: {
-            Summary("Immediately applying \(\.$preset) override", table: "ShortcutsDetail") {
+            Summary("Immediately applying \(\.$preset) override") {
                 \.$confirmBeforeApplying
             }
         })
@@ -49,7 +49,7 @@ struct ApplyOverridePresetIntent: AppIntent {
                 // Request user selection if no preset is provided
                 presetToApply = try await $preset.requestDisambiguation(
                     among: await OverridePresetsIntentRequest().fetchAndProcessOverrides(),
-                    dialog: IntentDialog(LocalizedStringResource("Select override", table: "ShortcutsDetail"))
+                    dialog: IntentDialog(LocalizedStringResource("Select override"))
                 )
             }
 
@@ -59,10 +59,11 @@ struct ApplyOverridePresetIntent: AppIntent {
             if confirmBeforeApplying {
                 try await requestConfirmation(
                     result: .result(
-                        dialog: IntentDialog(LocalizedStringResource(
-                            "Confirm to apply override '\(displayName)'",
-                            table: "ShortcutsDetail"
-                        ))
+                        dialog: IntentDialog(
+                            LocalizedStringResource(
+                                "Confirm to apply override '\(displayName)'"
+                            )
+                        )
                     )
                 )
             }
@@ -72,8 +73,7 @@ struct ApplyOverridePresetIntent: AppIntent {
                 return .result(
                     dialog: IntentDialog(
                         LocalizedStringResource(
-                            "Override '\(presetToApply.name)' applied",
-                            table: "ShortcutsDetail"
+                            "Override '\(presetToApply.name)' applied"
                         )
                     )
                 )
@@ -81,8 +81,7 @@ struct ApplyOverridePresetIntent: AppIntent {
                 return .result(
                     dialog: IntentDialog(
                         LocalizedStringResource(
-                            "Override '\(presetToApply.name)' failed",
-                            table: "ShortcutsDetail"
+                            "Override '\(presetToApply.name)' failed"
                         )
                     )
                 )

+ 3 - 3
Trio/Sources/Shortcuts/Override/CancelOverrideIntent.swift

@@ -4,10 +4,10 @@ import Foundation
 /// An App Intent that allows users to cancel an active override through the Shortcuts app.
 struct CancelOverrideIntent: AppIntent {
     /// The title displayed for this action in the Shortcuts app.
-    static var title = LocalizedStringResource("Cancel override", table: "ShortcutsDetail")
+    static var title = LocalizedStringResource("Cancel override")
 
     /// The description displayed for this action in the Shortcuts app.
-    static var description = IntentDescription(.init("Cancel an active override", table: "ShortcutsDetail"))
+    static var description = IntentDescription(.init("Cancel an active override"))
 
     /// Performs the intent action to cancel an active override.
     ///
@@ -16,7 +16,7 @@ struct CancelOverrideIntent: AppIntent {
     @MainActor func perform() async throws -> some ProvidesDialog {
         await OverridePresetsIntentRequest().cancelOverride()
         return .result(
-            dialog: IntentDialog(LocalizedStringResource("Override canceled", table: "ShortcutsDetail"))
+            dialog: IntentDialog(LocalizedStringResource("Override canceled"))
         )
     }
 }

+ 2 - 4
Trio/Sources/Shortcuts/TempPresets/ApplyTempPresetIntent.swift

@@ -80,8 +80,7 @@ struct ApplyTempPresetIntent: AppIntent {
                 return .result(
                     dialog: IntentDialog(
                         LocalizedStringResource(
-                            "Temporary Target '\(presetToApply.name)' applied",
-                            table: "ShortcutsDetail"
+                            "Temporary Target '\(presetToApply.name)' applied"
                         )
                     )
                 )
@@ -89,8 +88,7 @@ struct ApplyTempPresetIntent: AppIntent {
                 return .result(
                     dialog: IntentDialog(
                         LocalizedStringResource(
-                            "Temporary Target '\(presetToApply.name)' failed",
-                            table: "ShortcutsDetail"
+                            "Temporary Target '\(presetToApply.name)' failed"
                         )
                     )
                 )

+ 12 - 3
Trio/Sources/Views/SettingInputSection.swift

@@ -33,6 +33,8 @@ struct SettingInputSection<VerboseHint: View>: View {
     var verboseHint: VerboseHint
     var headerText: String?
     var footerText: String?
+    var isToggleDisabled: Bool = false
+    var miniHintColor: Color = .secondary
 
     @ObservedObject private var pickerSettingsProvider = PickerSettingsProvider.shared
     @State private var displayPicker: Bool = false
@@ -55,6 +57,7 @@ struct SettingInputSection<VerboseHint: View>: View {
 
                     case .boolean:
                         toggleView(label: label, isOn: $booleanValue)
+                            .disabled(isToggleDisabled)
 
                     case let .conditionalDecimal(key):
                         VStack {
@@ -73,7 +76,8 @@ struct SettingInputSection<VerboseHint: View>: View {
                     hintSection(
                         miniHint: miniHint,
                         shouldDisplayHint: $shouldDisplayHint,
-                        verboseHint: verboseHint
+                        verboseHint: verboseHint,
+                        miniHintColor: miniHintColor
                     )
                 }
             },
@@ -235,11 +239,16 @@ struct SettingInputSection<VerboseHint: View>: View {
         }.padding(.top)
     }
 
-    private func hintSection(miniHint: String, shouldDisplayHint: Binding<Bool>, verboseHint: VerboseHint) -> some View {
+    private func hintSection(
+        miniHint: String,
+        shouldDisplayHint: Binding<Bool>,
+        verboseHint: VerboseHint,
+        miniHintColor: Color = .secondary
+    ) -> some View {
         HStack(alignment: .center) {
             Text(miniHint)
                 .font(.footnote)
-                .foregroundColor(.secondary)
+                .foregroundColor(miniHintColor)
                 .lineLimit(nil)
             Spacer()
             Button(action: {