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

Merge branch 'dev' of github.com:nightscout/Trio-dev into add-ting-range

Deniz Cengiz 1 год назад
Родитель
Сommit
5145c47cd8
27 измененных файлов с 465 добавлено и 245 удалено
  1. 4 0
      Trio.xcodeproj/project.pbxproj
  2. 2 2
      Trio/Sources/APS/FetchGlucoseManager.swift
  3. 1 0
      Trio/Sources/Application/AppDelegate.swift
  4. 7 10
      Trio/Sources/Application/TrioApp.swift
  5. 53 3
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  6. 6 0
      Trio/Sources/Models/Determination.swift
  7. 5 1
      Trio/Sources/Modules/Adjustments/View/TempTargets/AdjustmentsRootView+TempTargets.swift
  8. 60 47
      Trio/Sources/Modules/CGMSettings/CGMSettingsStateModel.swift
  9. 5 1
      Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift
  10. 4 6
      Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift
  11. 17 6
      Trio/Sources/Modules/DataTable/View/DataTableRootView.swift
  12. 79 0
      Trio/Sources/Modules/Home/HomeStateModel+CGM.swift
  13. 26 64
      Trio/Sources/Modules/Home/HomeStateModel.swift
  14. 6 6
      Trio/Sources/Modules/NightscoutConfig/View/NightscoutConfigRootView.swift
  15. 29 37
      Trio/Sources/Modules/Settings/SettingItems.swift
  16. 5 0
      Trio/Sources/Modules/Settings/SettingsStateModel.swift
  17. 17 5
      Trio/Sources/Modules/Settings/View/SettingsRootView.swift
  18. 25 13
      Trio/Sources/Modules/Stat/StatStateModel+Setup/LoopChartSetup.swift
  19. 1 7
      Trio/Sources/Modules/Stat/StatStateModel.swift
  20. 1 1
      Trio/Sources/Modules/Stat/View/ViewElements/Glucose/GlucoseMetricsView.swift
  21. 2 8
      Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopBarChartView.swift
  22. 6 1
      Trio/Sources/Modules/Stat/View/ViewElements/Looping/LoopStatsView.swift
  23. 4 2
      Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift
  24. 16 10
      Trio/Sources/Modules/Treatments/View/ForecastChart.swift
  25. 70 10
      Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
  26. 2 2
      Trio/Sources/Services/RemoteControl/TrioRemoteControl+Meal.swift
  27. 12 3
      Trio/Sources/Views/SettingInputSection.swift

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -204,6 +204,7 @@
 		38FEF413273B317A00574A46 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FEF412273B317A00574A46 /* HKUnit.swift */; };
 		3B2F77862D7E52ED005ED9FA /* TDD.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77852D7E52ED005ED9FA /* TDD.swift */; };
 		3B2F77882D7E5387005ED9FA /* CurrentTDDSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.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 */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
@@ -925,6 +926,7 @@
 		38FEF412273B317A00574A46 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = "<group>"; };
 		3B2F77852D7E52ED005ED9FA /* TDD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TDD.swift; sourceTree = "<group>"; };
 		3B2F77872D7E5387005ED9FA /* CurrentTDDSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentTDDSetup.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>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
@@ -1755,6 +1757,7 @@
 				3811DE2A25C9D49500A708ED /* HomeDataFlow.swift */,
 				3811DE2925C9D49500A708ED /* HomeProvider.swift */,
 				3811DE2825C9D49500A708ED /* HomeStateModel.swift */,
+				3B4196DF2D8C4BBB0091DFF7 /* HomeStateModel+CGM.swift */,
 				58645B972CA2D16A008AFCE7 /* HomeStateModel+Setup */,
 				3811DE2C25C9D49500A708ED /* View */,
 			);
@@ -3879,6 +3882,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 */,

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

+ 7 - 10
Trio/Sources/Application/TrioApp.swift

@@ -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,
@@ -127,14 +132,6 @@ extension Notification.Name {
                 }
             }
         }
-
-        Task {
-            do {
-                try await BuildDetails.shared.handleExpireDateChange()
-            } catch {
-                debug(.default, "Failed to handle expire date change: \(error)")
-            }
-        }
     }
 
     /// Attempts to initialize the CoreDataStack again after a previous failure.

+ 53 - 3
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",
@@ -18744,6 +18747,7 @@
     },
     "Activate Dynamic Carb Ratio (CR)" : {
       "comment" : "Enable Dyn CR",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -19145,6 +19149,7 @@
     },
     "Activate Dynamic Sensitivity (ISF)" : {
       "comment" : "Enable Dyn ISF",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -19661,6 +19666,9 @@
         }
       }
     },
+    "Add a CGM and pump to enable automated insuin delivery" : {
+
+    },
     "Add a Garmin Device to Trio." : {
       "localizations" : {
         "bg" : {
@@ -25580,6 +25588,7 @@
     },
     "Adjustment Factor" : {
       "comment" : "Headline \"Adjustment Factor\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -26711,6 +26720,7 @@
       }
     },
     "Algorithm" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -27226,6 +27236,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" : {
@@ -27753,6 +27766,7 @@
       }
     },
     "Allow Fetching From Nightscout" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -28365,6 +28379,7 @@
       }
     },
     "Allow SMB With High Temporary Target" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -53698,6 +53713,7 @@
       }
     },
     "Create Calendar Events" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -55652,6 +55668,7 @@
 
     },
     "Dark Mode" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -55752,6 +55769,7 @@
       }
     },
     "Dark Scheme" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -62765,6 +62783,9 @@
         }
       }
     },
+    "Delete Carbs Equivalents?" : {
+      "comment" : "Alert title for deleting carb equivalents"
+    },
     "Delete Carbs?" : {
       "comment" : "Delete carbs from data table and Nightscout",
       "extractionState" : "manual",
@@ -63820,6 +63841,9 @@
         }
       }
     },
+    "Delete the Temp Target Preset \"%@\"?" : {
+      "comment" : "Delete confirmation title for temporary target presets"
+    },
     "Delivery limits" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -65360,6 +65384,7 @@
       }
     },
     "DIA" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -66597,6 +66622,7 @@
       }
     },
     "Display and Allow Fat and Protein Entries" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -67734,6 +67760,7 @@
       }
     },
     "Display on Watch" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -72782,6 +72809,7 @@
       }
     },
     "Enable Fatty Meal Factor" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -74096,6 +74124,7 @@
       }
     },
     "Enable SMB With Temporary Target" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -74405,6 +74434,7 @@
       }
     },
     "Enable Super Bolus" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -83280,6 +83310,7 @@
       }
     },
     "Fat and Protein Factor" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -83985,6 +84016,7 @@
     },
     "Fatty Meal Factor" : {
       "comment" : "For the  Bolus View pop-up",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -84185,6 +84217,7 @@
       }
     },
     "Features" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -84385,6 +84418,7 @@
       }
     },
     "Fetch and Remote Control" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -87229,6 +87263,7 @@
       }
     },
     "FPU" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -93613,6 +93648,7 @@
       }
     },
     "High BG Target" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -94230,6 +94266,7 @@
     },
     "High Temptarget Raises Sensitivity" : {
       "comment" : "Headline \"High Temptarget Raises Sensitivity\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -106027,6 +106064,7 @@
 
     },
     "Light Mode" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -106127,6 +106165,7 @@
       }
     },
     "Light Scheme" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -110655,6 +110694,7 @@
     },
     "Low Temptarget Lowers Sensitivity" : {
       "comment" : "Headline ”Low Temptarget Lowers Sensitivity\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -114954,6 +114994,7 @@
     },
     "Max UAM SMB Basal Minutes" : {
       "comment" : "Headline \"Max UAM SMB Basal Minutes\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -115555,6 +115596,7 @@
       }
     },
     "Maximum Duration (hours)" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -117825,6 +117867,7 @@
     },
     "Min 5m Carbimpact" : {
       "comment" : "Headline \"Min 5m Carbimpact\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -121128,6 +121171,7 @@
       }
     },
     "Nightscout Fetch & Remote Control" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -121429,6 +121473,7 @@
       }
     },
     "Nightscout Upload" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -130681,9 +130726,6 @@
     "Override choice" : {
 
     },
-    "Override eA1c Unit" : {
-
-    },
     "Override HbA1c Unit" : {
       "extractionState" : "stale",
       "localizations" : {
@@ -141075,6 +141117,7 @@
     },
     "Remaining Carbs Fraction" : {
       "comment" : "Headline \"Remaining Carbs Fraction\"",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -153204,6 +153247,7 @@
       }
     },
     "Show Protein and Fat" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -157571,6 +157615,7 @@
       }
     },
     "Spread Interval (minutes)" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -157772,6 +157817,7 @@
       }
     },
     "Standing / Laying TIR Chart" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -161439,6 +161485,7 @@
       }
     },
     "Super Bolus Factor" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -169590,6 +169637,7 @@
       }
     },
     "Therapy" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -190936,6 +190984,7 @@
       }
     },
     "Watch Complication" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -194820,6 +194869,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

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

+ 17 - 6
Trio/Sources/Modules/DataTable/View/DataTableRootView.swift

@@ -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")) }
 }

+ 5 - 0
Trio/Sources/Modules/Settings/SettingsStateModel.swift

@@ -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
+        }
     }
 }
 

+ 17 - 5
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
@@ -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
                 )
             ]
         }

+ 1 - 7
Trio/Sources/Modules/Stat/StatStateModel.swift

@@ -14,13 +14,7 @@ extension Stat {
         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] = []

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

@@ -42,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) {

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

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

+ 70 - 10
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
+            }
 
-            fetchedEnactedDetermination = modifiedFetchedEnactedDetermination
+            enacted.reason = injectTDD(into: enacted.reason, tdd: tdd)
+            enacted.tdd = tdd
+
+            fetchedEnactedDetermination = enacted
         }
 
         // Gather all relevant data for OpenAPS Status
@@ -1453,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
+    }
+}

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

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