Browse Source

Merge pull request #1243 from trioneer-dev/feat/move-snooze-all-to-settings

Move "Snooze All" to Settings and Clean up Legacy Alerting; Follow-Up to #1203 – Prepare O5 and Libre 3/3+ Integration [3/3]
Deniz Cengiz 1 week ago
parent
commit
cb9725a8d7
34 changed files with 616 additions and 859 deletions
  1. 8 49
      Trio.xcodeproj/project.pbxproj
  2. 0 9
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  3. 32 8
      Trio/Sources/APS/CGM/PluginSource.swift
  4. 5 0
      Trio/Sources/APS/FetchGlucoseManager.swift
  5. 29 0
      Trio/Sources/Helpers/TimeInterval+Convenience.swift
  6. 57 7
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  7. 1 47
      Trio/Sources/Logger/Logger.swift
  8. 19 2
      Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift
  9. 3 1
      Trio/Sources/Models/NotificationIdentifiers.swift
  10. 1 1
      Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift
  11. 32 11
      Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift
  12. 27 1
      Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift
  13. 1 1
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift
  14. 69 35
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift
  15. 1 0
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift
  16. 4 0
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  17. 0 261
      Trio/Sources/Modules/Main/MainStateModel.swift
  18. 7 2
      Trio/Sources/Modules/Settings/SettingItems.swift
  19. 31 35
      Trio/Sources/Modules/Settings/View/Subviews/NotificationsView.swift
  20. 0 5
      Trio/Sources/Modules/Snooze/SnoozeDataFlow.swift
  21. 0 3
      Trio/Sources/Modules/Snooze/SnoozeProvider.swift
  22. 0 46
      Trio/Sources/Modules/Snooze/SnoozeStateModel.swift
  23. 0 158
      Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift
  24. 0 28
      Trio/Sources/Router/Router.swift
  25. 0 3
      Trio/Sources/Router/Screen.swift
  26. 75 3
      Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift
  27. 18 0
      Trio/Sources/Services/Alerts/AlertCatalogVendor.swift
  28. 1 1
      Trio/Sources/Services/Alerts/NotLoopingMonitor.swift
  29. 43 0
      Trio/Sources/Services/Alerts/TrioAlertManager.swift
  30. 1 132
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift
  31. 21 6
      Trio/Sources/Services/WatchManager/GarminManager.swift
  32. 1 1
      Trio/Sources/Views/SettingInputSection.swift
  33. 73 0
      Trio/Sources/Views/SnoozeAlertsSheetView.swift
  34. 56 3
      TrioTests/AlertCatalogRegistryEntriesTests.swift

+ 8 - 49
Trio.xcodeproj/project.pbxproj

@@ -8,7 +8,6 @@
 
 /* Begin PBXBuildFile section */
 		041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D1A7CA8C10C4403D4BBFA7 /* TreatmentsDataFlow.swift */; };
-		0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5822B15939E719628E9FF7C /* SnoozeRootView.swift */; };
 		0D9A5E34A899219C5C4CDFAF /* HistoryStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9455FA2D92E77A6C4AFED8A3 /* HistoryStateModel.swift */; };
 		110AEDE32C5193D200615CC9 /* BolusIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE02C5193D100615CC9 /* BolusIntent.swift */; };
 		110AEDE42C5193D200615CC9 /* BolusIntentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE12C5193D100615CC9 /* BolusIntentRequest.swift */; };
@@ -95,6 +94,7 @@
 		38192E04261B82FA0094D973 /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38192E03261B82FA0094D973 /* ReachabilityManager.swift */; };
 		38192E07261BA9960094D973 /* FetchTreatmentsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38192E06261BA9960094D973 /* FetchTreatmentsManager.swift */; };
 		38192E0D261BAF980094D973 /* ConvenienceExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38192E0C261BAF980094D973 /* ConvenienceExtensions.swift */; };
+		D7C5E1F4A7B91E2F3C5D6E70 /* TimeInterval+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C5E1F4A7B91E2F3C5D6E71 /* TimeInterval+Convenience.swift */; };
 		3821ED4C25DD18BA00BC42AD /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3821ED4B25DD18BA00BC42AD /* Constants.swift */; };
 		382C133725F13A1E00715CE1 /* InsulinSensitivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */; };
 		382C134B25F14E3700715CE1 /* BGTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 382C134A25F14E3700715CE1 /* BGTargets.swift */; };
@@ -160,7 +160,6 @@
 		38DAB280260CBB7F00F74C1A /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DAB27F260CBB7F00F74C1A /* PumpView.swift */; };
 		38DAB28A260D349500F74C1A /* FetchGlucoseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DAB289260D349500F74C1A /* FetchGlucoseManager.swift */; };
 		38DF1786276A73D400B3528F /* TagCloudView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DF1785276A73D400B3528F /* TagCloudView.swift */; };
-		38DF1789276FC8C400B3528F /* SwiftMessages in Frameworks */ = {isa = PBXBuildFile; productRef = 38DF1788276FC8C400B3528F /* SwiftMessages */; };
 		38DF178D27733E6800B3528F /* snow.sks in Resources */ = {isa = PBXBuildFile; fileRef = 38DF178B27733E6800B3528F /* snow.sks */; };
 		38DF178E27733E6800B3528F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 38DF178C27733E6800B3528F /* Assets.xcassets */; };
 		38DF179027733EAD00B3528F /* SnowScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DF178F27733EAD00B3528F /* SnowScene.swift */; };
@@ -444,17 +443,14 @@
 		6B1A8D242B14D91700E76752 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6B1A8D232B14D91700E76752 /* Assets.xcassets */; };
 		6B1A8D282B14D91700E76752 /* LiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6B1A8D172B14D91600E76752 /* LiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
 		6B1A8D2E2B156EEF00E76752 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B1A8D2D2B156EEF00E76752 /* LiveActivityManager.swift */; };
-		6B1F539F9FF75646D1606066 /* SnoozeDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36A708CDB546692C2230B385 /* SnoozeDataFlow.swift */; };
 		6BCF84DD2B16843A003AD46E /* LiveActitiyAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF84DC2B16843A003AD46E /* LiveActitiyAttributes.swift */; };
 		6BCF84DE2B16843A003AD46E /* LiveActitiyAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF84DC2B16843A003AD46E /* LiveActitiyAttributes.swift */; };
 		6EADD581738D64431902AC0A /* (null) in Sources */ = {isa = PBXBuildFile; };
-		6FFAE524D1D9C262F2407CAE /* SnoozeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CAE81192B118804DCD23034 /* SnoozeProvider.swift */; };
 		711C0CB42CAABE788916BC9D /* ManualTempBasalDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96653287EDB276A111288305 /* ManualTempBasalDataFlow.swift */; };
 		71D44AAB2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71D44AAA2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift */; };
 		72F1BD388F42FCA6C52E4500 /* ConfigEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44080E4709E3AE4B73054563 /* ConfigEditorProvider.swift */; };
 		7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C377490C77661D75E8C50649 /* ManualTempBasalRootView.swift */; };
 		7F7B756BE8543965D9FDF1A2 /* HistoryDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A401509D21F7F35D4E109EDA /* HistoryDataFlow.swift */; };
-		8194B80890CDD6A3C13B0FEE /* SnoozeStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26904AACA8D9C15D229D675 /* SnoozeStateModel.swift */; };
 		88AB39B23C9552BD6E0C9461 /* ISFEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB3BAE7494CB771ABAC7B8B /* ISFEditorRootView.swift */; };
 		8A91342A2D63D9A1007F8874 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8A9134292D63D9A1007F8874 /* Localizable.xcstrings */; };
 		8A91342C2D63D9A2007F8874 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 8A91342B2D63D9A2007F8874 /* InfoPlist.xcstrings */; };
@@ -684,6 +680,7 @@
 		DD1745262C55526F00211FAC /* SMBSettingsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745252C55526F00211FAC /* SMBSettingsRootView.swift */; };
 		DD1745292C55642100211FAC /* SettingInputSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745282C55642100211FAC /* SettingInputSection.swift */; };
 		DD17452B2C556E8100211FAC /* SettingInputHintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452A2C556E8100211FAC /* SettingInputHintView.swift */; };
+		DD500A712807000000000101 /* SnoozeAlertsSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD500A712807000000000100 /* SnoozeAlertsSheetView.swift */; };
 		DD17452E2C55AE4800211FAC /* TargetBehavoirDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452D2C55AE4800211FAC /* TargetBehavoirDataFlow.swift */; };
 		DD1745302C55AE5300211FAC /* TargetBehaviorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD17452F2C55AE5300211FAC /* TargetBehaviorProvider.swift */; };
 		DD1745322C55AE6000211FAC /* TargetBehavoirStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1745312C55AE6000211FAC /* TargetBehavoirStateModel.swift */; };
@@ -1058,11 +1055,9 @@
 		19F95FF429F10FCF00314DDC /* StatProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatProvider.swift; sourceTree = "<group>"; };
 		19F95FF629F10FEE00314DDC /* StatStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatStateModel.swift; sourceTree = "<group>"; };
 		19F95FF929F1102A00314DDC /* StatRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatRootView.swift; sourceTree = "<group>"; };
-		1CAE81192B118804DCD23034 /* SnoozeProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeProvider.swift; sourceTree = "<group>"; };
 		223EC0494F55A91E3EA69EF4 /* TreatmentsStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TreatmentsStateModel.swift; sourceTree = "<group>"; };
 		2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigRootView.swift; sourceTree = "<group>"; };
 		2F2A13DF0EDEEEDC4106AA2A /* NightscoutConfigDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigDataFlow.swift; sourceTree = "<group>"; };
-		36A708CDB546692C2230B385 /* SnoozeDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeDataFlow.swift; sourceTree = "<group>"; };
 		36F58DDD71F0E795464FA3F0 /* TargetsEditorStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorStateModel.swift; sourceTree = "<group>"; };
 		3811DE0725C9D32E00A708ED /* BaseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseView.swift; sourceTree = "<group>"; };
 		3811DE0825C9D32F00A708ED /* BaseProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseProvider.swift; sourceTree = "<group>"; };
@@ -1115,6 +1110,7 @@
 		38192E03261B82FA0094D973 /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = "<group>"; };
 		38192E06261BA9960094D973 /* FetchTreatmentsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTreatmentsManager.swift; sourceTree = "<group>"; };
 		38192E0C261BAF980094D973 /* ConvenienceExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConvenienceExtensions.swift; sourceTree = "<group>"; };
+		D7C5E1F4A7B91E2F3C5D6E71 /* TimeInterval+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Convenience.swift"; sourceTree = "<group>"; };
 		3821ED4B25DD18BA00BC42AD /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
 		382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsulinSensitivities.swift; sourceTree = "<group>"; };
 		382C134A25F14E3700715CE1 /* BGTargets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGTargets.swift; sourceTree = "<group>"; };
@@ -1471,7 +1467,6 @@
 		B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidator.swift; sourceTree = "<group>"; };
 		B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidatorTests.swift; sourceTree = "<group>"; };
 		B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = "<group>"; };
-		B5822B15939E719628E9FF7C /* SnoozeRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeRootView.swift; sourceTree = "<group>"; };
 		B6E925122EB3932A0076D719 /* OmnipodKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmnipodKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		B9B5C0607505A38F256BF99A /* CGMSettingsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CGMSettingsDataFlow.swift; sourceTree = "<group>"; };
 		BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorDataFlow.swift; sourceTree = "<group>"; };
@@ -1688,6 +1683,7 @@
 		DD1745252C55526F00211FAC /* SMBSettingsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBSettingsRootView.swift; sourceTree = "<group>"; };
 		DD1745282C55642100211FAC /* SettingInputSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingInputSection.swift; sourceTree = "<group>"; };
 		DD17452A2C556E8100211FAC /* SettingInputHintView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingInputHintView.swift; sourceTree = "<group>"; };
+		DD500A712807000000000100 /* SnoozeAlertsSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnoozeAlertsSheetView.swift; sourceTree = "<group>"; };
 		DD17452D2C55AE4800211FAC /* TargetBehavoirDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehavoirDataFlow.swift; sourceTree = "<group>"; };
 		DD17452F2C55AE5300211FAC /* TargetBehaviorProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehaviorProvider.swift; sourceTree = "<group>"; };
 		DD1745312C55AE6000211FAC /* TargetBehavoirStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TargetBehavoirStateModel.swift; sourceTree = "<group>"; };
@@ -1888,7 +1884,6 @@
 		E06B9119275B5EEA003C04B6 /* Array+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extension.swift"; sourceTree = "<group>"; };
 		E0CC2C5B275B9DAE00A7BC71 /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = System/Library/Frameworks/HealthKit.framework; sourceTree = SDKROOT; };
 		E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitSample.swift; sourceTree = "<group>"; };
-		E26904AACA8D9C15D229D675 /* SnoozeStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnoozeStateModel.swift; sourceTree = "<group>"; };
 		E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickEntry.swift; sourceTree = "<group>"; };
 		E592A3712CEEC038009A472C /* ContactImageRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageRootView.swift; sourceTree = "<group>"; };
 		E592A3732CEEC038009A472C /* ContactImageDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageDataFlow.swift; sourceTree = "<group>"; };
@@ -1950,7 +1945,6 @@
 				CE95BF5F2BA7715800DC3DE3 /* MockKit.framework in Frameworks */,
 				DD17A0292E3FE0BD008E1BF0 /* SwiftJWT in Frameworks */,
 				3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */,
-				38DF1789276FC8C400B3528F /* SwiftMessages in Frameworks */,
 				3B4BA7842D8DBD690069D5B8 /* RileyLinkKit.framework in Frameworks */,
 				CE95BF612BA7715900DC3DE3 /* MockKitUI.framework in Frameworks */,
 				E0CC2C5C275B9F0F00A7BC71 /* HealthKit.framework in Frameworks */,
@@ -2012,14 +2006,6 @@
 			path = ConfigEditor;
 			sourceTree = "<group>";
 		};
-		0A67A70F9438DB6586398458 /* View */ = {
-			isa = PBXGroup;
-			children = (
-				B5822B15939E719628E9FF7C /* SnoozeRootView.swift */,
-			);
-			path = View;
-			sourceTree = "<group>";
-		};
 		0D76BBC81CEDC1A0050F45EF /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -2217,17 +2203,6 @@
 			path = View;
 			sourceTree = "<group>";
 		};
-		29B478DF61BF8D270F7D8954 /* Snooze */ = {
-			isa = PBXGroup;
-			children = (
-				36A708CDB546692C2230B385 /* SnoozeDataFlow.swift */,
-				1CAE81192B118804DCD23034 /* SnoozeProvider.swift */,
-				E26904AACA8D9C15D229D675 /* SnoozeStateModel.swift */,
-				0A67A70F9438DB6586398458 /* View */,
-			);
-			path = Snooze;
-			sourceTree = "<group>";
-		};
 		34CA4DF169B53D67EF18ED8A /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -2275,7 +2250,6 @@
 				3811DE3825C9D4A100A708ED /* Settings */,
 				110AEDEA2C51A0AE00615CC9 /* ShortcutsConfig */,
 				DD17451E2C55520000211FAC /* SMBSettings */,
-				29B478DF61BF8D270F7D8954 /* Snooze */,
 				19F95FF129F10F9C00314DDC /* Stat */,
 				DD17452C2C55AE3500211FAC /* TargetBehavoir */,
 				6517011F19F244F64E1FF14B /* TargetsEditor */,
@@ -2717,6 +2691,7 @@
 				DD1745282C55642100211FAC /* SettingInputSection.swift */,
 				48B83503461B4F8D97B30115 /* SettingsSearchHighlight.swift */,
 				DD17452A2C556E8100211FAC /* SettingInputHintView.swift */,
+				DD500A712807000000000100 /* SnoozeAlertsSheetView.swift */,
 			);
 			path = Views;
 			sourceTree = "<group>";
@@ -2898,6 +2873,7 @@
 				49239B422EEA27AD00469145 /* TempTargetCalculations.swift */,
 				DDCAE8322D78D49C00B1BB51 /* TherapySettingsUtil.swift */,
 				DDD5889C2DDDC9A900C8848D /* TimeAgoFormatter.swift */,
+				D7C5E1F4A7B91E2F3C5D6E71 /* TimeInterval+Convenience.swift */,
 				BD2FF19F2AE29D43005D1C5D /* ToggleStyles.swift */,
 				CEB434E428B8FF5D00B70274 /* UIColor.swift */,
 				FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */,
@@ -4571,7 +4547,6 @@
 				3811DE0F25C9D37700A708ED /* Swinject */,
 				38B17B6525DD90E0005CAE3D /* SwiftDate */,
 				3833B46C26012030003021B3 /* Algorithms */,
-				38DF1788276FC8C400B3528F /* SwiftMessages */,
 				CEB434FC28B90B7C00B70274 /* SwiftCharts */,
 				B958F1B62BA0711600484851 /* MKRingProgressView */,
 				3BD9687B2D8DDD4600899469 /* SlideButton */,
@@ -4749,7 +4724,6 @@
 				3811DE0E25C9D37700A708ED /* XCRemoteSwiftPackageReference "Swinject" */,
 				38B17B6425DD90E0005CAE3D /* XCRemoteSwiftPackageReference "SwiftDate" */,
 				3833B46B26012030003021B3 /* XCRemoteSwiftPackageReference "swift-algorithms" */,
-				38DF1787276FC8C300B3528F /* XCRemoteSwiftPackageReference "SwiftMessages" */,
 				CEB434FB28B90B7C00B70274 /* XCRemoteSwiftPackageReference "SwiftCharts" */,
 				B958F1B52BA0711600484851 /* XCRemoteSwiftPackageReference "MKRingProgressView" */,
 				3BD9687A2D8DDD4600899469 /* XCRemoteSwiftPackageReference "SlideButton" */,
@@ -5019,6 +4993,7 @@
 				BDC2EA452C3043B000E5BBD0 /* OverrideStorage.swift in Sources */,
 				3811DE4225C9D4A100A708ED /* SettingsDataFlow.swift in Sources */,
 				DDD5889D2DDDC9A900C8848D /* TimeAgoFormatter.swift in Sources */,
+				D7C5E1F4A7B91E2F3C5D6E70 /* TimeInterval+Convenience.swift in Sources */,
 				CEE9A6562BBB418300EB5194 /* CalibrationsRootView.swift in Sources */,
 				3B5CD2B72D4AEA6600CE213C /* ComputedBGTargets.swift in Sources */,
 				3B5CD2B82D4AEA6600CE213C /* ComputedInsulinSensitivities.swift in Sources */,
@@ -5060,6 +5035,7 @@
 				19D466A729AA2C22004D5F33 /* MealSettingsStateModel.swift in Sources */,
 				AC19EF2C94084B5BA0175D1D /* SettingsSearchHighlight.swift in Sources */,
 				DD17452B2C556E8100211FAC /* SettingInputHintView.swift in Sources */,
+				DD500A712807000000000101 /* SnoozeAlertsSheetView.swift in Sources */,
 				38E44528274E401C00EC9A94 /* Protected.swift in Sources */,
 				DD3F1F8B2D9E08B600DCE7B3 /* NightscoutLoginStepView.swift in Sources */,
 				3811DEB625C9D88300A708ED /* UnlockManager.swift in Sources */,
@@ -5599,14 +5575,10 @@
 				DD4A00212DAEEED800AB7387 /* OnboardingView+AlgorithmUtil.swift in Sources */,
 				DDE179712C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift in Sources */,
 				CE7CA3502A064973004BE681 /* CancelTempPresetIntent.swift in Sources */,
-				6B1F539F9FF75646D1606066 /* SnoozeDataFlow.swift in Sources */,
-				6FFAE524D1D9C262F2407CAE /* SnoozeProvider.swift in Sources */,
 				DD3C47B52DC57E06003DD20D /* MainMigrationErrorView.swift in Sources */,
 				BD4ED4FD2CF9D5E8000EDC9C /* AppState.swift in Sources */,
 				DDF847DD2C5C28720049BB3B /* LiveActivitySettingsDataFlow.swift in Sources */,
-				8194B80890CDD6A3C13B0FEE /* SnoozeStateModel.swift in Sources */,
 				BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */,
-				0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */,
 				BD175EBE0000100000000001 /* HistoryDataFlow+Models.swift in Sources */,
 				BD175EBE0000100000000002 /* HistoryDeletionTarget.swift in Sources */,
 				BD175EBE0000100000000003 /* HistoryStateModel+Glucose.swift in Sources */,
@@ -6408,14 +6380,6 @@
 				minimumVersion = 6.3.1;
 			};
 		};
-		38DF1787276FC8C300B3528F /* XCRemoteSwiftPackageReference "SwiftMessages" */ = {
-			isa = XCRemoteSwiftPackageReference;
-			repositoryURL = "https://github.com/SwiftKickMobile/SwiftMessages";
-			requirement = {
-				kind = upToNextMajorVersion;
-				minimumVersion = 9.0.0;
-			};
-		};
 		3B47C60E2DA0A28F00B0E5EF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
 			isa = XCRemoteSwiftPackageReference;
 			repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git";
@@ -6490,11 +6454,6 @@
 			package = 38B17B6425DD90E0005CAE3D /* XCRemoteSwiftPackageReference "SwiftDate" */;
 			productName = SwiftDate;
 		};
-		38DF1788276FC8C400B3528F /* SwiftMessages */ = {
-			isa = XCSwiftPackageProductDependency;
-			package = 38DF1787276FC8C300B3528F /* XCRemoteSwiftPackageReference "SwiftMessages" */;
-			productName = SwiftMessages;
-		};
 		3B47C60F2DA0A28F00B0E5EF /* FirebaseCrashlytics */ = {
 			isa = XCSwiftPackageProductDependency;
 			package = 3B47C60E2DA0A28F00B0E5EF /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;

+ 0 - 9
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -254,15 +254,6 @@
       }
     },
     {
-      "identity" : "swiftmessages",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/SwiftKickMobile/SwiftMessages",
-      "state" : {
-        "revision" : "62e12e138fc3eedf88c7553dd5d98712aa119f40",
-        "version" : "9.0.9"
-      }
-    },
-    {
       "identity" : "swinject",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/Swinject/Swinject",

+ 32 - 8
Trio/Sources/APS/CGM/PluginSource.swift

@@ -99,23 +99,47 @@ extension PluginSource: CGMManagerDelegate {
         debug(.deviceManager, "device Manager for \(String(describing: deviceIdentifier)) : \(message)")
     }
 
-    func issueAlert(_: LoopKit.Alert) {}
+    /// Forwards CGMManager-issued alerts into the unified `TrioAlertManager`
+    /// pipeline so they get the same in-app banner + UN scheduling + history
+    /// logging treatment as everything else. Used to be a no-op; on
+    /// dev-libre3 / LibreLoop builds that meant CGM-issued alerts (sensor
+    /// failure, signal loss, expiry, etc.) silently dropped.
+    func issueAlert(_ alert: LoopKit.Alert) {
+        glucoseManager?.trioAlertManager?.issueAlert(alert)
+    }
 
-    func retractAlert(identifier _: LoopKit.Alert.Identifier) {}
+    func retractAlert(identifier: LoopKit.Alert.Identifier) {
+        glucoseManager?.trioAlertManager?.retractAlert(identifier: identifier)
+    }
 
-    func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {}
+    /// LoopKit asks this on reconnect to avoid re-issuing an alert that's
+    /// still live. `TrioAlertManager` deduplicates downstream via its own
+    /// throttler and live-alert table, so answering "no" here is safe — at
+    /// worst we get one duplicate banner, which the throttler suppresses.
+    func doesIssuedAlertExist(identifier _: LoopKit.Alert.Identifier, completion: @escaping (Result<Bool, Error>) -> Void) {
+        completion(.success(false))
+    }
 
     func lookupAllUnretracted(
         managerIdentifier _: String,
-        completion _: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
-    ) {}
+        completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
+    ) {
+        completion(.success([]))
+    }
 
     func lookupAllUnacknowledgedUnretracted(
         managerIdentifier _: String,
-        completion _: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
-    ) {}
+        completion: @escaping (Result<[LoopKit.PersistedAlert], Error>) -> Void
+    ) {
+        completion(.success([]))
+    }
 
-    func recordRetractedAlert(_: LoopKit.Alert, at _: Date) {}
+    /// LoopKit calls this when a manager itself decides an alert is no
+    /// longer relevant. Mirror the action on our pipeline so the in-app
+    /// banner, scheduled UN, and history all clear.
+    func recordRetractedAlert(_ alert: LoopKit.Alert, at _: Date) {
+        glucoseManager?.trioAlertManager?.retractAlert(identifier: alert.identifier)
+    }
 
     func cgmManagerWantsDeletion(_ manager: CGMManager) {
         processQueue.async { [weak self] in

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

@@ -21,6 +21,10 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     var shouldSyncToRemoteService: Bool { get }
     var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
     var cgmProgressHighlight: CurrentValueSubject<DeviceLifecycleProgress?, Never> { get }
+    /// Routes CGMManager-issued alerts (sensor failure, signal loss, expiry,
+    /// etc.) into the unified `TrioAlertManager` pipeline. Read by
+    /// `PluginSource.issueAlert` / `retractAlert`.
+    var trioAlertManager: TrioAlertManager! { get }
 }
 
 extension FetchGlucoseManager {
@@ -42,6 +46,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     @Injected() var deviceDataManager: DeviceDataManager!
     @Injected() var pluginCGMManager: PluginManager!
     @Injected() var calibrationService: CalibrationService!
+    @Injected() var trioAlertManager: TrioAlertManager!
 
     private var lifetime = Lifetime()
     private let timer = DispatchTimer(timeInterval: 1.minutes.timeInterval)

+ 29 - 0
Trio/Sources/Helpers/TimeInterval+Convenience.swift

@@ -0,0 +1,29 @@
+import Foundation
+
+/// Convenience constructors + getters used across the app for `TimeInterval`
+/// math at minute/hour granularity. Previously lived inside the deleted
+/// `Snooze` module; broken out here because real callers exist outside the
+/// alert pipeline (chart markers, pump-history windows, fetch timers, etc.).
+extension TimeInterval {
+    static func seconds(_ seconds: Double) -> TimeInterval { seconds }
+
+    static func minutes(_ minutes: Double) -> TimeInterval {
+        TimeInterval(minutes: minutes)
+    }
+
+    static func hours(_ hours: Double) -> TimeInterval {
+        TimeInterval(minutes: hours * 60)
+    }
+
+    init(minutes: Double) {
+        self.init(minutes * 60)
+    }
+
+    init(hours: Double) {
+        self.init(minutes: hours * 60)
+    }
+
+    var minutes: Double { self / 60.0 }
+
+    var hours: Double { minutes / 60.0 }
+}

+ 57 - 7
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -8906,6 +8906,9 @@
         }
       }
     },
+    "%@ Device Alerts" : {
+      "comment" : "Device Alerts help sheet label; text reads: '<severity level> Device Alerts'."
+    },
     "%@ g" : {
       "localizations" : {
         "bg" : {
@@ -47219,7 +47222,7 @@
         }
       }
     },
-    "Applies To" : {
+    "Applies To (Cannot be changed)" : {
 
     },
     "Apply a Temporary Target" : {
@@ -62897,6 +62900,9 @@
         }
       }
     },
+    "Calibration unavailable" : {
+
+    },
     "Calibrationinfo" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -72357,6 +72363,7 @@
 
     },
     "Click to Snooze Alerts" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -85022,7 +85029,7 @@
         }
       }
     },
-    "Default notification banner. Suppressed by Silence & Focus Mode." : {
+    "Default notification banner. Suppressed by Silence & Focus Mode by default." : {
 
     },
     "Default Percent: 70%" : {
@@ -123548,6 +123555,9 @@
         }
       }
     },
+    "For everyday heads-up notifications. These behave like a standard banner — they stay quiet when your phone is silenced or a Focus is on. Examples: an algorithm error, a sensor expiration reminder, or a time-zone change being detected." : {
+
+    },
     "For example, at a temp target of %@ %@, your basal is reduced to 50%%, but this scales depending on the target (e.g., 75%% at %@ %@, 60%% at %@ %@)." : {
       "localizations" : {
         "bg" : {
@@ -124050,6 +124060,12 @@
         }
       }
     },
+    "For situations that require prompt attention. These break through Silent Mode, Do Not Disturb, and any Focus you have enabled. Examples: a pump fault, an occlusion, or Trio not looping for too long. Heads up: if your build of Trio has Apple's Critical Alerts entitlement, iOS plays its own critical alert sound and the sound you picked for this alert category is ignored." : {
+
+    },
+    "For things you should know about soon, but not 'act right now'. These can break through banner suppression on the lock screen, but they still obey Silent Mode and Focus by default. Examples: reservoir running low, pod or patch expiring soon, or glucose data going stale." : {
+
+    },
     "For write data to Apple Health you must give permissions in Settings > Health > Data Access" : {
       "comment" : "Show when have not permissions for writing to Health",
       "extractionState" : "manual",
@@ -129472,6 +129488,9 @@
         }
       }
     },
+    "Glucose is stale for calibration" : {
+
+    },
     "Glucose is very low." : {
       "localizations" : {
         "bg" : {
@@ -135570,6 +135589,7 @@
     },
     "High Glucose Alarm active" : {
       "comment" : "High Glucose Alarm active",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -145513,7 +145533,7 @@
       }
     },
     "Info" : {
-      "comment" : "Info icon in main view\nInfo title",
+      "comment" : "Info icon in main view",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -160771,6 +160791,7 @@
     },
     "Low Glucose Alarm active" : {
       "comment" : "Low Glucose Alarm active",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -176160,6 +176181,9 @@
         }
       }
     },
+    "Mute Trio Alarms" : {
+
+    },
     "Name" : {
       "comment" : "Preset name",
       "localizations" : {
@@ -179068,6 +179092,7 @@
     },
     "No Glucose Alarm active" : {
       "comment" : "No Glucose Alarm active",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -181475,6 +181500,9 @@
         }
       }
     },
+    "No URL available" : {
+
+    },
     "Noise:" : {
       "localizations" : {
         "bg" : {
@@ -183508,6 +183536,7 @@
     },
     "not snoozing" : {
       "comment" : "not snoozing",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -190287,6 +190316,9 @@
         }
       }
     },
+    "Open failed" : {
+
+    },
     "Open iOS Settings" : {
       "comment" : "Manage Permissions in Settings button text",
       "localizations" : {
@@ -191692,6 +191724,9 @@
         }
       }
     },
+    "Other Notifications" : {
+
+    },
     "Other third-party services, like Apple Health or Tidepool, can be added later through the settings menu." : {
       "localizations" : {
         "bg" : {
@@ -193844,9 +193879,6 @@
     "Overrides Focus" : {
 
     },
-    "Overrides Silence & Focus Mode" : {
-
-    },
     "Overrides Silence & Focus Mode. For situations requiring immediate attention." : {
 
     },
@@ -197284,6 +197316,9 @@
         }
       }
     },
+    "Pick a duration to mute every Trio alarm. Critical alerts (e.g. occlusion, urgent low) still pierce the snooze." : {
+
+    },
     "Pick Treatment" : {
       "localizations" : {
         "bg" : {
@@ -197408,7 +197443,7 @@
         }
       }
     },
-    "Pierces banner suppression but obeys Silence & Focus Mode." : {
+    "Pierces banner suppression but obeys Silence & Focus Mode by default." : {
 
     },
     "Play Sound" : {
@@ -230370,6 +230405,9 @@
     "Snooze all (15 min)" : {
 
     },
+    "Snooze All Alarms" : {
+
+    },
     "snooze from testview clicked" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -230501,8 +230539,12 @@
         }
       }
     },
+    "Snoozed until %@" : {
+
+    },
     "snoozing until %@" : {
       "comment" : "snoozing until %@",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -236042,6 +236084,7 @@
       }
     },
     "Strength" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -244085,6 +244128,9 @@
         }
       }
     },
+    "The app Garmin Connect must be installed to use Trio.\nGo to the App Store to download it." : {
+
+    },
     "The Autosens Ratio is used to calculate the amount of adjustment needed to basal rates, ISF, and CR." : {
       "localizations" : {
         "bg" : {
@@ -272928,6 +272974,9 @@
         }
       }
     },
+    "Unable to open the app" : {
+
+    },
     "Unable to render payload." : {
 
     },
@@ -280667,6 +280716,7 @@
     },
     "Warning" : {
       "comment" : "Warning title",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {

+ 1 - 47
Trio/Sources/Logger/Logger.swift

@@ -5,7 +5,6 @@ import UIKit
 var LoggerTestMode = false
 
 private let baseReporter = TrioApp.resolver.resolve(GroupedIssueReporter.self)!
-private let router = TrioApp.resolver.resolve(Router.self)!
 
 let loggerLock = NSRecursiveLock()
 
@@ -28,37 +27,13 @@ func debug(
 func info(
     _ category: Logger.Category,
     _ message: String,
-    type: MessageType = .info,
     file: String = #file,
     function: String = #function,
     line: UInt = #line
 ) {
     DispatchWorkItem(qos: .background, flags: .enforceQoS) {
         loggerLock.perform {
-            category.logger.info(message, type: type, file: file, function: function, line: line)
-        }
-    }.perform()
-}
-
-func info(
-    _ category: Logger.Category,
-    _ message: String,
-    notificationText: String,
-    type: MessageType = .info,
-    file: String = #file,
-    function: String = #function,
-    line: UInt = #line
-) {
-    DispatchWorkItem(qos: .background, flags: .enforceQoS) {
-        loggerLock.perform {
-            category.logger.info(
-                message,
-                notificationText: notificationText,
-                type: type,
-                file: file,
-                function: function,
-                line: line
-            )
+            category.logger.info(message, file: file, function: function, line: line)
         }
     }.perform()
 }
@@ -268,18 +243,6 @@ final class Logger {
 
     func info(
         _ message: String,
-        type: MessageType = .info,
-        file: String = #file,
-        function: String = #function,
-        line: UInt = #line
-    ) {
-        info(message, notificationText: message, type: type, file: file, function: function, line: line)
-    }
-
-    func info(
-        _ message: String,
-        notificationText: String,
-        type: MessageType = .info,
         file: String = #file,
         function: String = #function,
         line: UInt = #line
@@ -287,8 +250,6 @@ final class Logger {
         let printedMessage = "INFO: \(message)"
         os_log("%@ - %@ - %d %{public}@", log: log, type: .info, file.file, function, line, printedMessage)
         reporter.log(category.name, printedMessage, file: file, function: function, line: line)
-
-        showAlert(notificationText, type: type)
     }
 
     func warning(
@@ -324,13 +285,6 @@ final class Logger {
         )
     }
 
-    private func showAlert(_ message: String, type: MessageType = .info) {
-        DispatchQueue.main.async {
-            let messageCont = MessageContent(content: message, type: type)
-            router.alertMessage.send(messageCont)
-        }
-    }
-
     fileprivate func errorWithoutFatalError(
         _ message: String,
         description: String? = nil,

+ 19 - 2
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift

@@ -35,9 +35,26 @@ enum DeviceAlertSeverity: String, Codable, CaseIterable, Identifiable {
         case .critical:
             return String(localized: "Overrides Silence & Focus Mode. For situations requiring immediate attention.")
         case .timeSensitive:
-            return String(localized: "Pierces banner suppression but obeys Silence & Focus Mode.")
+            return String(localized: "Pierces banner suppression but obeys Silence & Focus Mode by default.")
         case .normal:
-            return String(localized: "Default notification banner. Suppressed by Silence & Focus Mode.")
+            return String(localized: "Default notification banner. Suppressed by Silence & Focus Mode by default.")
+        }
+    }
+
+    var hintText: String {
+        switch self {
+        case .critical:
+            return String(
+                localized: "For situations that require prompt attention. These break through Silent Mode, Do Not Disturb, and any Focus you have enabled. Examples: a pump fault, an occlusion, or Trio not looping for too long. Heads up: if your build of Trio has Apple's Critical Alerts entitlement, iOS plays its own critical alert sound and the sound you picked for this alert category is ignored."
+            )
+        case .timeSensitive:
+            return String(
+                localized: "For things you should know about soon, but not 'act right now'. These can break through banner suppression on the lock screen, but they still obey Silent Mode and Focus by default. Examples: reservoir running low, pod or patch expiring soon, or glucose data going stale."
+            )
+        case .normal:
+            return String(
+                localized: "For everyday heads-up notifications. These behave like a standard banner — they stay quiet when your phone is silenced or a Focus is on. Examples: an algorithm error, a sensor expiration reminder, or a time-zone change being detected."
+            )
         }
     }
 

+ 3 - 1
Trio/Sources/Models/NotificationIdentifiers.swift

@@ -54,11 +54,13 @@ enum NotificationCategoryFactory {
             )
         }
 
+        // `.customDismissAction` — otherwise iOS swallows swipes silently and
+        // the in-app banner outlives the notification.
         return UNNotificationCategory(
             identifier: NotificationCategoryIdentifier.trioAlert.rawValue,
             actions: snoozeActions,
             intentIdentifiers: [],
-            options: []
+            options: [.customDismissAction]
         )
     }
 }

+ 1 - 1
Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift

@@ -88,7 +88,7 @@ extension AlarmWindows {
                                                 )
                                             )
                                             Text(
-                                                "These windows are shared between Glucose Alarms and Pump Alarms."
+                                                "These windows are shared between Glucose Alarms and Device Alarms."
                                             )
                                         }
                                     )

+ 32 - 11
Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift

@@ -1,3 +1,4 @@
+import LoopKit
 import LoopKitUI
 import SwiftUI
 import Swinject
@@ -21,11 +22,31 @@ extension CGMSettings {
         @State private var period: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_Period")
         @State private var noiseAmplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_NoiseAmplitude")
         @State private var produceStaleValues: Bool = UserDefaults.standard.bool(forKey: "GlucoseSimulator_ProduceStaleValues")
-
+      
         /// Drives the synthetic `cgmStatusHighlight`
         @State private var simulatedScenarioRaw: String = UserDefaults.standard
             .string(forKey: "GlucoseSimulator.simulatedScenario") ?? SimulatedSensorScenario.runningNormally.rawValue
 
+        /// Routes "open URL failed" warnings through `TrioAlertManager` so
+        /// they share the same in-app banner UI as the rest of the alert
+        /// pipeline (no more SwiftMessages roundtrip).
+        private func warnOpenFailed(identifier: String, title: String, body: String) {
+            let content = Alert.Content(
+                title: title,
+                body: body,
+                acknowledgeActionButtonLabel: String(localized: "OK")
+            )
+            let alert = Alert(
+                identifier: Alert.Identifier(managerIdentifier: "trio.cgmSettings", alertIdentifier: identifier),
+                foregroundContent: content,
+                backgroundContent: content,
+                trigger: .immediate,
+                interruptionLevel: .active,
+                sound: nil
+            )
+            resolver.resolve(TrioAlertManager.self)?.issueAlert(alert)
+        }
+
         // Initialize state variables with defaults if needed
         private func initializeSimulatorSettings() {
             if centerValue == 0 {
@@ -69,11 +90,11 @@ extension CGMSettings {
                                 Button {
                                     UIApplication.shared.open(appURL, options: [:]) { success in
                                         if !success {
-                                            self.router.alertMessage
-                                                .send(MessageContent(
-                                                    content: "Unable to open the app",
-                                                    type: .warning
-                                                ))
+                                            warnOpenFailed(
+                                                identifier: "cgm.app.open.failed",
+                                                title: String(localized: "Open failed"),
+                                                body: String(localized: "Unable to open the app")
+                                            )
                                         }
                                     }
                                 }
@@ -156,11 +177,11 @@ extension CGMSettings {
                         Button {
                             UIApplication.shared.open(url, options: [:]) { success in
                                 if !success {
-                                    self.router.alertMessage
-                                        .send(MessageContent(
-                                            content: "No URL available",
-                                            type: .warning
-                                        ))
+                                    warnOpenFailed(
+                                        identifier: "nightscout.open.failed",
+                                        title: String(localized: "Open failed"),
+                                        body: String(localized: "No URL available")
+                                    )
                                 }
                             }
                         }

+ 27 - 1
Trio/Sources/Modules/Calibrations/CalibrationsStateModel.swift

@@ -1,4 +1,5 @@
 import CoreData
+import LoopKit
 import Observation
 import SwiftDate
 import SwiftUI
@@ -7,6 +8,7 @@ extension Calibrations {
     @Observable final class StateModel: BaseStateModel<Provider> {
         @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
         @ObservationIgnored @Injected() var calibrationService: CalibrationService!
+        @ObservationIgnored @Injected() var trioAlertManager: TrioAlertManager!
 
         var slope: Double = 1
         var intercept: Double = 1
@@ -77,7 +79,8 @@ extension Calibrations {
 
                     calibrationService.addCalibration(calibration)
                 } else {
-                    info(.service, "Glucose is stale for calibration")
+                    debug(.service, "Glucose is stale for calibration")
+                    issueStaleGlucoseAlert()
                     return
                 }
             } catch {
@@ -85,6 +88,29 @@ extension Calibrations {
             }
         }
 
+        /// Surfaces the "glucose too stale to calibrate against" condition as
+        /// a one-shot info alert through `TrioAlertManager`. Mirrors the old
+        /// `info(.service, …)` banner path that ran via `router.alertMessage`.
+        private func issueStaleGlucoseAlert() {
+            let content = Alert.Content(
+                title: String(localized: "Calibration unavailable"),
+                body: String(localized: "Glucose is stale for calibration"),
+                acknowledgeActionButtonLabel: String(localized: "OK")
+            )
+            let alert = Alert(
+                identifier: Alert.Identifier(
+                    managerIdentifier: "trio.calibration",
+                    alertIdentifier: "glucose.stale"
+                ),
+                foregroundContent: content,
+                backgroundContent: content,
+                trigger: .immediate,
+                interruptionLevel: .active,
+                sound: nil
+            )
+            trioAlertManager?.issueAlert(alert)
+        }
+
         func removeLast() {
             calibrationService.removeLast()
             setupCalibrations()

+ 1 - 1
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift

@@ -60,7 +60,7 @@ struct DeviceAlarmEditorView: View {
                     soundFilename: $working.soundFilename
                 )
 
-                Section(header: Text("Applies To")) {
+                Section(header: Text("Applies To (Cannot be changed)")) {
                     ForEach(conceptsForTier(working.severity), id: \.self) { concept in
                         Text(concept.displayTitle)
                             .font(.footnote)

+ 69 - 35
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift

@@ -4,11 +4,13 @@ import Swinject
 private enum DeviceAlarmSheet: Identifiable {
     case picker
     case editor(DeviceAlertSeverityConfig, isNew: Bool)
+    case help(DeviceAlertSeverity)
 
     var id: String {
         switch self {
         case .picker: return "picker"
         case let .editor(config, _): return config.id.uuidString
+        case let .help(severity): return "helpSheet_" + severity.id
         }
     }
 }
@@ -22,16 +24,43 @@ extension DeviceAlarms {
         @State private var sheet: DeviceAlarmSheet?
         @State private var pendingNewSeverity: DeviceAlertSeverity?
 
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: AnyView?
+
         @Environment(\.colorScheme) var colorScheme
         @Environment(AppState.self) var appState
 
         var body: some View {
             List {
                 ForEach(DeviceAlertSeverity.allCases) { severity in
-                    Section(
-                        header: header(for: severity),
-                        footer: footer(for: severity)
-                    ) {
+                    Section {
+                        VStack(alignment: .leading, spacing: 5) {
+                            HStack {
+                                Image(systemName: severityIcon(for: severity))
+                                    .foregroundStyle(severityTint(for: severity))
+                                Text(severity.displayName)
+                                Spacer()
+                            }.font(.headline)
+
+                            HStack(alignment: .center) {
+                                Text(severity.blurb)
+                                    .font(.footnote)
+                                    .foregroundColor(.secondary)
+                                    .lineLimit(nil)
+                                    .fixedSize(horizontal: false, vertical: true)
+                                Spacer()
+                                Button(action: {
+                                    sheet = .help(severity)
+                                }) {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                                .buttonStyle(BorderlessButtonStyle())
+                            }.padding(.vertical, 5)
+                        }
+
                         ForEach(store.configs(in: severity)) { config in
                             row(for: config)
                                 .opacity(config.isEnabled ? 1 : 0.5)
@@ -41,12 +70,13 @@ extension DeviceAlarms {
 
                 Section {
                     Text("Day & Night Windows")
+                        .foregroundStyle(Color.accentColor)
                         .navigationLink(to: .alarmWindows, from: self)
                 }.listRowBackground(Color.chart)
             }
             .scrollContentBackground(.hidden)
             .background(appState.trioBackgroundColor(for: colorScheme))
-            .navigationTitle("Pump Alarms")
+            .navigationTitle("Device Alarms")
             .navigationBarTitleDisplayMode(.inline)
             .toolbar {
                 ToolbarItem(placement: .primaryAction) {
@@ -68,6 +98,24 @@ extension DeviceAlarms {
                         onDone: { sheet = nil },
                         onCancel: { sheet = nil }
                     )
+                case let .help(severity):
+                    SettingInputHintView(
+                        hintDetent: $hintDetent,
+                        shouldDisplayHint: Binding(
+                            get: { sheet != nil },
+                            set: { if !$0 { sheet = nil } }
+                        ),
+                        hintLabel: String(
+                            localized: "\(severity.displayName) Device Alerts",
+                            comment: "Device Alerts help sheet label; text reads: '<severity level> Device Alerts'."
+                        ),
+                        hintText: selectedVerboseHint ?? AnyView(
+                            VStack(alignment: .leading, spacing: 10) {
+                                Text(severity.hintText)
+                            }
+                        ),
+                        sheetTitle: String(localized: "Help", comment: "Help sheet title")
+                    )
                 }
             }
             .onAppear(perform: configureView)
@@ -85,20 +133,6 @@ extension DeviceAlarms {
             }
         }
 
-        // MARK: - Section header / footer
-
-        private func header(for severity: DeviceAlertSeverity) -> some View {
-            HStack {
-                Image(systemName: severityIcon(for: severity))
-                    .foregroundStyle(severityTint(for: severity))
-                Text(severity.displayName)
-            }
-        }
-
-        private func footer(for severity: DeviceAlertSeverity) -> some View {
-            Text(severity.blurb)
-        }
-
         // MARK: - Row
 
         @ViewBuilder private func row(for config: DeviceAlertSeverityConfig) -> some View {
@@ -131,22 +165,22 @@ extension DeviceAlarms {
         }
 
         private func soundSummary(for config: DeviceAlertSeverityConfig) -> some View {
-            let icon: String
-            let label: String
-            if !config.playsSound {
-                icon = "speaker.slash.fill"
-                label = String(localized: "Sound off")
-            } else if config.overridesSilenceAndDND {
-                icon = "speaker.wave.3.fill"
-                label =
-                    "\(AlarmSoundCatalog.displayName(for: config.soundFilename)) • \(String(localized: "Overrides Silence & Focus Mode"))"
-            } else {
-                icon = "speaker.fill"
-                label = AlarmSoundCatalog.displayName(for: config.soundFilename)
-            }
-            return HStack(spacing: 4) {
-                Image(systemName: icon)
-                Text(label)
+            // Show the sound and override facts independently. Previously
+            // the override badge was hidden when sound was off, but "sound
+            // off + override on" is a valid combo (silent + haptic that
+            // breaks through Focus / Sleep) and the user needs to see it.
+            HStack(spacing: 6) {
+                HStack(spacing: 4) {
+                    Image(systemName: config.playsSound ? "speaker.wave.2.fill" : "speaker.slash.fill")
+                    Text(config.playsSound ? "Sound on" : "Sound off")
+                }
+                if config.overridesSilenceAndDND {
+                    Text("·")
+                    HStack(spacing: 4) {
+                        Image(systemName: "bell.badge.fill")
+                        Text("Overrides Focus")
+                    }
+                }
             }
             .font(.footnote)
             .foregroundColor(.secondary)

+ 1 - 0
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift

@@ -64,6 +64,7 @@ extension GlucoseAlerts {
                 // FIXME: make this into a nice setting with mini and verbose hint
                 Section {
                     Text("Day & Night Windows")
+                        .foregroundStyle(Color.accentColor)
                         .navigationLink(to: .alarmWindows, from: self)
                 }.listRowBackground(Color.chart)
 

+ 4 - 0
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -34,6 +34,7 @@ extension Home {
         @State var selectedTab: Int = 0
         @State var showPumpSelection: Bool = false
         @State var showCGMSelection: Bool = false
+        @State var showSnoozeSheet: Bool = false
         @State var notificationsDisabled = false
         @State var timeButtons: [TimePicker] = [
             TimePicker(active: false, hours: 4),
@@ -995,6 +996,9 @@ extension Home {
             .sheet(isPresented: $state.isLegendPresented) {
                 ChartLegendView(state: state)
             }
+            .sheet(isPresented: $showSnoozeSheet) {
+                SnoozeAlertsSheetView(resolver: resolver, isPresented: $showSnoozeSheet)
+            }
             // PUMP RELATED
             .confirmationDialog("Pump Model", isPresented: $showPumpSelection) {
                 Button("Medtronic") { state.addPump(.minimed) }

+ 0 - 261
Trio/Sources/Modules/Main/MainStateModel.swift

@@ -1,6 +1,5 @@
 import Combine
 import LoopKitUI
-import SwiftMessages
 import SwiftUI
 import Swinject
 
@@ -12,194 +11,6 @@ extension Main {
         @Published var modal: Modal?
         @Published var secondaryModal: SecondaryModalWrapper?
 
-        @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
-        private var timers: [TimeInterval: Timer] = [:]
-
-        private func showTriggeredView(
-            message: MessageContent,
-            interval _: TimeInterval,
-            config: SwiftMessages.Config,
-            view: MessageView
-        ) {
-            view.customConfigureTheme(
-                colorSchemePreference: colorSchemePreference
-            )
-            setupAction(message: message, view: view)
-
-            SwiftMessages.show(config: config, view: view)
-        }
-
-        // Add or replace timer for a specific TimeInterval
-        private func addOrReplaceTriggerTimer(message: MessageContent, config: SwiftMessages.Config, view: MessageView) {
-            let trigger = message.trigger as! UNTimeIntervalNotificationTrigger
-            guard trigger.timeInterval > 0 else { return }
-            let interval = trigger.timeInterval
-
-            SwiftMessages.hide(id: view.id)
-
-            // If a timer already exists for this interval, invalidate it
-            if let existingTimer = timers[interval] {
-                existingTimer.invalidate()
-            }
-
-            // Create a new timer with the provided interval
-            let newTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
-                self?.showTriggeredView(message: message, interval: interval, config: config, view: view)
-                self?.timers[interval] = nil
-            }
-
-            timers[interval] = newTimer
-        }
-
-        // Cancel all timers (optional cleanup method)
-        private func cancelAllTimers() {
-            timers.values.forEach { $0.invalidate() }
-            timers.removeAll()
-        }
-
-        private func setupPumpConfig() {
-            // display the pump configuration immediatly
-            if let pump = provider.deviceManager.pumpManager,
-               let bluetooth = provider.bluetoothProvider
-            {
-                let view = PumpConfig.PumpSettingsView(
-                    pumpManager: pump,
-                    bluetoothManager: bluetooth,
-                    completionDelegate: self
-                ).asAny()
-                router.mainSecondaryModalView.send(view)
-            }
-        }
-
-        private func setupButton(message _: MessageContent, view: MessageView) {
-            view.button?.setImage(UIImage(), for: .normal)
-            view.iconLabel = nil
-            let buttonImage = UIImage(systemName: "chevron.right")?.withTintColor(.white)
-            view.button?.setImage(buttonImage, for: .normal)
-            view.button?.backgroundColor = view.backgroundView.backgroundColor
-            view.button?.tintColor = view.iconImageView?.tintColor
-        }
-
-        private func setupAction(message: MessageContent, view: MessageView) {
-            switch message.action {
-            case .snooze:
-                setupButton(message: message, view: view)
-                view.buttonTapHandler = { _ in
-                    // Popup Snooze view when user taps on Glucose Notification
-                    SwiftMessages.hide()
-                    self.router.mainModalScreen.send(.snooze)
-                }
-            case .pumpConfig:
-                setupButton(message: message, view: view)
-                view.buttonTapHandler = { _ in
-                    SwiftMessages.hide()
-                    self.setupPumpConfig()
-                }
-            default: // break
-                view.button?.setImage(UIImage(), for: .normal)
-                view.buttonTapHandler = { _ in
-                    SwiftMessages.hide()
-                }
-            }
-        }
-
-        private func isApnPumpConfigAction(_ message: MessageContent) -> Bool {
-            if message.type != .error, message.action == .pumpConfig {
-                setupPumpConfig()
-                return true
-            }
-            return false
-        }
-
-        private func showAlertMessage(_ message: MessageContent) {
-            if message.useAPN, !alertPermissionsChecker.notificationsDisabled
-            {
-                showAPN(message)
-            } else {
-                showSwiftMessage(message)
-            }
-        }
-
-        private func showAPN(_ message: MessageContent) {
-            DispatchQueue.main.async {
-                self.broadcaster.notify(alertMessageNotificationObserver.self, on: .main) {
-                    $0.alertMessageNotification(message)
-                }
-            }
-        }
-
-        // Read the color scheme preference from UserDefaults; defaults to system default setting
-        @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
-
-        private func showSwiftMessage(_ message: MessageContent) {
-            if snoozeUntilDate > Date(), message.action == .snooze {
-                return
-            }
-
-            var config = SwiftMessages.defaultConfig
-            let view = MessageView.viewFromNib(layout: .cardView)
-
-            view.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
-            config.prefersStatusBarHidden = true
-
-            // Set id so that multiple notifications are not queued while waiting for user response; only the latest will be shown
-            if message.subtype == .glucose || message.subtype == .carb {
-                view.id = message.type.rawValue + message.subtype.rawValue
-            }
-
-            let titleContent: String
-
-            let iconName = UIApplication.shared.alternateIconName ?? "trioBlack"
-            let iconImage = UIImage(named: iconName) ?? UIImage()
-
-            view.configureContent(
-                title: "title",
-                body: message.content,
-                iconImage: nil,
-                iconText: nil,
-                buttonImage: nil,
-                buttonTitle: nil,
-                buttonTapHandler: nil
-            )
-
-            view.configureIcon(withSize: CGSize(width: 40, height: 40), contentMode: .scaleAspectFit)
-            view.iconImageView!.image = iconImage
-            view.iconImageView?.layer.cornerRadius = 10
-
-            view.customConfigureTheme(
-                colorSchemePreference: colorSchemePreference
-            )
-
-            view.iconImageView?.image = iconImage
-
-            switch message.type {
-            case .info,
-                 .other:
-                config.duration = .seconds(seconds: 5)
-                titleContent = message.title != "" ? message.title : String(localized: "Info", comment: "Info title")
-            case .warning:
-                config.duration = .forever
-                titleContent = message.title != "" ? message
-                    .title : String(localized: "Warning", comment: "Warning title")
-            case .error:
-                config.duration = .forever
-                titleContent = message.title != "" ? message
-                    .title : String(localized: "Error", comment: "Error title")
-            }
-
-            view.titleLabel?.text = titleContent
-            config.dimMode = .gray(interactive: true)
-
-            setupAction(message: message, view: view)
-            if message.trigger != nil {
-                addOrReplaceTriggerTimer(message: message, config: config, view: view)
-            }
-
-            guard message.type == .error || message.action != .pumpConfig, message.trigger == nil, !view.isHidden else { return }
-
-            SwiftMessages.show(config: config, view: view)
-        }
-
         override func subscribe() {
             router.mainModalScreen
                 .map { $0?.modal(resolver: self.resolver!) }
@@ -215,14 +26,6 @@ extension Main {
                 }
                 .store(in: &lifetime)
 
-            router.alertMessage
-                .receive(on: DispatchQueue.main)
-                .sink { message in
-                    guard !self.isApnPumpConfigAction(message) else { return }
-                    self.showAlertMessage(message)
-                }
-                .store(in: &lifetime)
-
             router.mainSecondaryModalView
                 .receive(on: DispatchQueue.main)
                 .sink { view in
@@ -240,67 +43,3 @@ extension Main {
         }
     }
 }
-
-extension MessageView {
-    func currentColorScheme() -> ColorScheme {
-        let userInterfaceStyle = UITraitCollection.current.userInterfaceStyle
-        return userInterfaceStyle == .dark ? .dark : .light
-    }
-
-    func customConfigureTheme(colorSchemePreference: ColorSchemeOption) {
-        let defaultSystemColorScheme = currentColorScheme()
-        var backgroundColor = UIColor.systemBackground
-        var foregroundColor = UIColor.white
-        let ApnBackground = UIColor(named: "ApnBackground") ?? UIColor.lightGray
-        let iOSlightTrioDark = UIColor(named: "ApnBackgroundLightDark") ?? UIColor.lightGray
-
-        switch colorSchemePreference {
-        case .systemDefault:
-            backgroundColor = ApnBackground
-            foregroundColor = UIColor.label
-        case .dark:
-            backgroundColor = defaultSystemColorScheme == .light ? iOSlightTrioDark : ApnBackground
-            foregroundColor = defaultSystemColorScheme == .light ? UIColor.black : UIColor.white
-        case .light:
-            backgroundColor = defaultSystemColorScheme == .light ? ApnBackground : UIColor.gray
-            foregroundColor = defaultSystemColorScheme == .light ? UIColor.black : UIColor.white
-        }
-
-        iconImageView?.tintColor = foregroundColor
-        backgroundView.backgroundColor = backgroundColor
-        titleLabel?.textColor = foregroundColor
-        bodyLabel?.textColor = foregroundColor
-        iconImageView?.isHidden = iconImageView?.image == nil
-
-        backgroundView.layer.cornerRadius = 25
-
-        let adjustedFont = UIFont.systemFont(ofSize: 13.0, weight: .bold)
-        let preferredTitleFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: adjustedFont)
-        let preferredBodyFont = UIFont.preferredFontforStyle(forTextStyle: .footnote)
-        // Set the title and body font to the dynamic type sizes
-        titleLabel?.adjustsFontForContentSizeCategory = true
-        titleLabel?.font = preferredTitleFont
-        bodyLabel?.adjustsFontForContentSizeCategory = true
-        bodyLabel?.font = preferredBodyFont
-        // Set custom colors for title and body text
-        titleLabel?.textColor = foregroundColor
-        bodyLabel?.textColor = foregroundColor
-    }
-}
-
-@available(iOS 16.0, *)
-extension Main.StateModel: CompletionDelegate {
-    func completionNotifyingDidComplete(_: CompletionNotifying) {
-        // close the window
-        router.mainSecondaryModalView.send(nil)
-    }
-}
-
-// Extension to convert SwiftUI TextStyle to UIFont
-extension UIFont {
-    static func preferredFontforStyle(forTextStyle: UIFont.TextStyle) -> UIFont {
-        let uiFontMetrics = UIFontMetrics.default
-        let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: forTextStyle)
-        return uiFontMetrics.scaledFont(for: UIFont(descriptor: descriptor, size: 0))
-    }
-}

+ 7 - 2
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -351,9 +351,14 @@ enum SettingItems {
                 "Bolus Failed",
                 "Pod",
                 "Sensor Failure",
-                "Device Expired"
+                "Sensor Stopped",
+                "Calibration",
+                "Signal Loss",
+                "Device Expired",
+                "Not Looping",
+                "Algorithm"
             ],
-            path: ["Notifications", "Pump Alarms"]
+            path: ["Notifications", "Device Alarms"]
         ),
         SettingItem(
             title: "Day & Night Windows",

+ 31 - 35
Trio/Sources/Modules/Settings/View/Subviews/NotificationsView.swift

@@ -15,6 +15,7 @@ struct NotificationsView: BaseView {
     @ObservedObject var state: Settings.StateModel
     @State var notificationsDisabled = false
     @State var showAlert = false
+    @State private var showSnoozeSheet = false
     @State private var shouldDisplayHint: Bool = false
     @State var hintDetent = PresentationDetent.large
     @State var selectedVerboseHint: AnyView? = AnyView(
@@ -33,45 +34,32 @@ struct NotificationsView: BaseView {
     var body: some View {
         List {
             Section(
-                header: Text("Manage iOS Preferences"),
+                header: Text("Mute Trio Alarms"),
                 content: {
-                    manageNotifications
+                    VStack {
+                        Button {
+                            showSnoozeSheet = true
+                        } label: {
+                            HStack {
+                                Image(systemName: "zzz").foregroundStyle(.tint)
+                                Text("Snooze All Alarms").bold()
+                            }
+                            .font(.title3)
+                        }
+                        .frame(maxWidth: .infinity, alignment: .center)
+                        .buttonStyle(.bordered)
+                    }.padding(.vertical)
                 }
             ).listRowBackground(Color.chart)
 
-            Section {
-                VStack {
+            Section(
+                header: Text("Manage iOS Preferences"),
+                content: {
                     notificationsEnabledStatus
-                    HStack(alignment: .top) {
-                        Text("Notifications give you important Trio information without requiring you to open the app.")
-                            .font(.footnote)
-                            .foregroundColor(.secondary)
-                            .lineLimit(nil)
-                        Spacer()
-                        Button(
-                            action: {
-                                hintLabel = String(localized: "Manage iOS Preferences")
-                                selectedVerboseHint = AnyView(
-                                    VStack(alignment: .leading, spacing: 10) {
-                                        Text(
-                                            "Notifications give you important Trio information without requiring you to open the app."
-                                        )
-                                        Text(
-                                            "Keep these turned ON in your phone’s settings to ensure you receive Trio Notifications, Critical Alerts, and Time Sensitive Notifications."
-                                        )
-                                    }
-                                )
-                                shouldDisplayHint.toggle()
-                            },
-                            label: {
-                                HStack {
-                                    Image(systemName: "questionmark.circle")
-                                }
-                            }
-                        ).buttonStyle(BorderlessButtonStyle())
-                    }.padding(.top)
-                }.padding(.bottom)
-            }.listRowBackground(Color.chart)
+
+                    manageNotifications
+                }
+            ).listRowBackground(Color.chart)
 
             Section(
                 header: Text("Notification Center"),
@@ -79,12 +67,17 @@ struct NotificationsView: BaseView {
                     Text("Glucose Alarms")
                         .navigationLink(to: .glucoseAlerts, from: self)
 
-                    Text("Pump Alarms")
+                    Text("Device Alarms")
                         .navigationLink(to: .deviceAlarms, from: self)
 
                     Text("Day & Night Windows")
                         .navigationLink(to: .alarmWindows, from: self)
+                }
+            ).listRowBackground(Color.chart)
 
+            Section(
+                header: Text("Other Notifications"),
+                content: {
                     if #available(iOS 16.2, *) {
                         Text("Live Activity").navigationLink(to: .liveActivitySettings, from: self)
                     }
@@ -118,6 +111,9 @@ struct NotificationsView: BaseView {
                 sheetTitle: String(localized: "Help", comment: "Help sheet title")
             )
         }
+        .sheet(isPresented: $showSnoozeSheet) {
+            SnoozeAlertsSheetView(resolver: resolver, isPresented: $showSnoozeSheet)
+        }
         .scrollContentBackground(.hidden)
         .background(appState.trioBackgroundColor(for: colorScheme))
         .navigationTitle("Notifications")

+ 0 - 5
Trio/Sources/Modules/Snooze/SnoozeDataFlow.swift

@@ -1,5 +0,0 @@
-enum Snooze {
-    enum Config {}
-}
-
-protocol SnoozeProvider {}

+ 0 - 3
Trio/Sources/Modules/Snooze/SnoozeProvider.swift

@@ -1,3 +0,0 @@
-extension Snooze {
-    final class Provider: BaseProvider, SnoozeProvider {}
-}

+ 0 - 46
Trio/Sources/Modules/Snooze/SnoozeStateModel.swift

@@ -1,46 +0,0 @@
-import Observation
-import SwiftUI
-
-extension Snooze {
-    @Observable final class StateModel: BaseStateModel<Provider> {
-        @ObservationIgnored @Persisted(key: "UserNotificationsManager.snoozeUntilDate") var snoozeUntilDate: Date = .distantPast
-        @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
-        @ObservationIgnored @Injected() var notificationsManager: UserNotificationsManager!
-        @ObservationIgnored @Injected() var broadcaster: Broadcaster!
-
-        var alarm: GlucoseAlarm?
-
-        override func subscribe() {
-            alarm = glucoseStorage.alarm
-            broadcaster.register(SnoozeObserver.self, observer: self)
-        }
-
-        func unsubscribe() {
-            broadcaster.unregister(SnoozeObserver.self, observer: self)
-        }
-
-        // Add validation helper inside the class
-        private func validateSnoozeDuration(_ duration: TimeInterval) -> Bool {
-            // Only allow durations matching our defined actions
-            NotificationResponseAction.allCases
-                .map(\.duration)
-                .contains(duration)
-        }
-
-        @MainActor func applySnooze(_ duration: TimeInterval) async {
-            // Canonical path: notificationsManager.applySnooze → BaseTrioAlertManager
-            // writes the persisted date, mutes AlertMuter, clears pending UNs, and
-            // broadcasts SnoozeObserver. `snoozeDidChange` below then updates the
-            // @Persisted value here for SwiftUI observation.
-            alarm = glucoseStorage.alarm
-            await notificationsManager.applySnooze(for: duration)
-        }
-    }
-}
-
-extension Snooze.StateModel: SnoozeObserver {
-    func snoozeDidChange(_ untilDate: Date) {
-        snoozeUntilDate = untilDate
-        alarm = glucoseStorage.alarm
-    }
-}

+ 0 - 158
Trio/Sources/Modules/Snooze/View/SnoozeRootView.swift

@@ -1,158 +0,0 @@
-import AudioToolbox
-import SwiftUI
-import Swinject
-
-extension Snooze {
-    struct RootView: BaseView {
-        let resolver: Resolver
-        @State var state = StateModel()
-
-        @Environment(\.colorScheme) var colorScheme
-        @Environment(AppState.self) var appState
-
-        @State private var selectedInterval = 0
-        @State private var snoozeDescription = "nothing to see here"
-
-        private var pickerTimes: [TimeInterval] {
-            [
-                TimeInterval(minutes: 20), // 20 minutes
-                TimeInterval(hours: 1), // 1 hour
-                TimeInterval(hours: 3), // 3 hours
-                TimeInterval(hours: 6) // 6 hours
-            ]
-        }
-
-        private var formatter: DateComponentsFormatter {
-            let formatter = DateComponentsFormatter()
-            formatter.allowsFractionalUnits = false
-            formatter.unitsStyle = .full
-            return formatter
-        }
-
-        private var dateFormatter: DateFormatter {
-            let formatter = DateFormatter()
-            formatter.timeStyle = .short
-            return formatter
-        }
-
-        private func formatInterval(_ interval: TimeInterval) -> String {
-            formatter.string(from: interval) ?? ""
-        }
-
-        func getSnoozeDescription() -> String {
-            var snoozeDescription = ""
-            var celltext = ""
-
-            switch state.alarm {
-            case .high:
-                celltext = String(localized: "High Glucose Alarm active", comment: "High Glucose Alarm active")
-            case .low:
-                celltext = String(localized: "Low Glucose Alarm active", comment: "Low Glucose Alarm active")
-            case .none:
-                celltext = String(localized: "No Glucose Alarm active", comment: "No Glucose Alarm active")
-            }
-
-            if state.snoozeUntilDate > Date() {
-                snoozeDescription = String(
-                    format: String(localized: "snoozing until %@", comment: "snoozing until %@"),
-                    dateFormatter.string(from: state.snoozeUntilDate)
-                )
-            } else {
-                snoozeDescription = String(localized: "not snoozing", comment: "not snoozing")
-            }
-
-            return [celltext, snoozeDescription].joined(separator: ", ")
-        }
-
-        private var snoozeButton: some View {
-            VStack(alignment: .leading) {
-                Button {
-                    let interval = pickerTimes[selectedInterval]
-                    let snoozeFor = formatInterval(interval)
-                    let untilDate = Date() + interval
-
-                    Task { @MainActor [weak state] in
-                        guard let state = state else { return }
-                        await state.applySnooze(interval)
-                        debug(.default, "will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate))")
-                        snoozeDescription = getSnoozeDescription()
-                        state.hideModal()
-                    }
-                } label: {
-                    Text("Click to Snooze Alerts")
-                        .padding()
-                }
-            }
-        }
-
-        private var snoozePicker: some View {
-            VStack {
-                Picker(selection: $selectedInterval, label: Text("Strength")) {
-                    ForEach(0 ..< pickerTimes.count) {
-                        Text(formatInterval(self.pickerTimes[$0]))
-                    }
-                }
-                .pickerStyle(.wheel)
-            }
-        }
-
-        var body: some View {
-            Form {
-                Section {
-                    Text(snoozeDescription).lineLimit(nil)
-                    snoozePicker
-                    snoozeButton
-                }
-            }
-            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
-            .navigationBarTitle("Snooze Alerts")
-            .navigationBarTitleDisplayMode(.automatic)
-            .toolbar {
-                ToolbarItem(placement: .topBarTrailing) {
-                    Button("Close") {
-                        state.hideModal()
-                    }
-                }
-            }
-            .onAppear {
-                configureView()
-                snoozeDescription = getSnoozeDescription()
-            }
-            .onDisappear {
-                state.unsubscribe()
-            }
-        }
-    }
-}
-
-extension TimeInterval {
-    static func seconds(_ seconds: Double) -> TimeInterval {
-        seconds
-    }
-
-    static func minutes(_ minutes: Double) -> TimeInterval {
-        TimeInterval(minutes: minutes)
-    }
-
-    static func hours(_ hours: Double) -> TimeInterval {
-        TimeInterval(hours: hours)
-    }
-
-    init(minutes: Double) {
-        // self.init(minutes * 60)
-        let m = minutes * 60
-        self.init(m)
-    }
-
-    init(hours: Double) {
-        self.init(minutes: hours * 60)
-    }
-
-    var minutes: Double {
-        self / 60.0
-    }
-
-    var hours: Double {
-        minutes / 60.0
-    }
-}

+ 0 - 28
Trio/Sources/Router/Router.swift

@@ -2,43 +2,15 @@ import Combine
 import SwiftUI
 import Swinject
 
-enum MessageType: String {
-    case info
-    case warning
-    case error
-    case other
-}
-
-enum MessageSubtype: String {
-    case pump
-    case cgm
-    case carb
-    case glucose
-    case algorithm
-    case misc
-}
-
-struct MessageContent {
-    var content: String
-    var type: MessageType = .info
-    var subtype: MessageSubtype = .algorithm
-    var title: String = ""
-    var useAPN: Bool = true
-    var trigger: UNNotificationTrigger? = nil
-    var action: NotificationAction = .none
-}
-
 protocol Router {
     var mainModalScreen: CurrentValueSubject<Screen?, Never> { get }
     var mainSecondaryModalView: CurrentValueSubject<AnyView?, Never> { get }
-    var alertMessage: PassthroughSubject<MessageContent, Never> { get }
     func view(for screen: Screen) -> AnyView
 }
 
 final class BaseRouter: Router {
     let mainModalScreen = CurrentValueSubject<Screen?, Never>(nil)
     let mainSecondaryModalView = CurrentValueSubject<AnyView?, Never>(nil)
-    let alertMessage = PassthroughSubject<MessageContent, Never>()
     private let resolver: Resolver
 
     init(resolver: Resolver) {

+ 0 - 3
Trio/Sources/Router/Screen.swift

@@ -26,7 +26,6 @@ enum Screen: Identifiable, Hashable {
     case mealSettings
     case iconConfig
     case overrideConfig
-    case snooze
     case statistics
     case watch
     case userInterfaceSettings
@@ -119,8 +118,6 @@ extension Screen {
             IconConfig.RootView(resolver: resolver)
         case .overrideConfig:
             Adjustments.RootView(resolver: resolver)
-        case .snooze:
-            Snooze.RootView(resolver: resolver)
         case .watch:
             WatchConfig.RootView(resolver: resolver)
         case .statistics:

+ 75 - 3
Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift

@@ -7,12 +7,14 @@ import LoopKit
 /// registry. As plugins adopt `AlertCatalogVendor` upstream, their entries
 /// move out of here and into the plugin repo.
 ///
-/// Scope is pump alerts only. CGM lifecycle alerts (LibreLoop sensor end,
-/// etc.) pass through with the plugin's chosen level. Trio-internal alerts
+/// Scope covers pump + CGM-issued alerts (LibreLoop sensor expiry / attention
+/// / reconnect). G5/G6/One/G7/One+ don't go through `issueAlert` — their
+/// status surfaces via `cgmStatusHighlight` instead. Trio-internal alerts
 /// (glucose thresholds, algorithm error, not-looping) set their level at
 /// construction and don't need a catalog entry.
 enum AlertCatalogRegistry {
-    static let entries: [Alert.CatalogEntry] = omniEntries + minimedEntries + danaEntries + medtrumEntries
+    static let entries: [Alert.CatalogEntry] =
+        omniEntries + minimedEntries + danaEntries + medtrumEntries + libreLoopEntries + trioAlgorithmEntries
 
     static func lookup(_ identifier: Alert.Identifier) -> Alert.CatalogEntry? {
         if let exact = entries.first(where: { $0.identifier == identifier }) {
@@ -164,6 +166,76 @@ private extension AlertCatalogRegistry {
     ]
 }
 
+// MARK: - LibreLoop (FreeStyle Libre 3)
+
+private extension AlertCatalogRegistry {
+    /// Manager identifier matches `LibreLoopCGMManager.pluginIdentifier`.
+    /// Alert identifiers come from `LibreLoopExpiryAlerts` +
+    /// `LibreLoopCGMManager.sensorAttentionAlertID` /
+    /// `needsReScanAlertID`. The 24 h warning is advance notice
+    /// (`.timeSensitive`); the 2 h warning, end-of-session, and replace-
+    /// needed escalate to `.critical`. Reconnect-needs-rescan is a user-
+    /// action prompt — `.timeSensitive`.
+    static let libreLoopEntries: [Alert.CatalogEntry] = [
+        addEntry(
+            "LibreLoopCGMManager",
+            "sensorExpiry.warning24h",
+            .timeSensitive,
+            "Sensor Expires in 24 Hours",
+            "Sensor",
+            .cgmExpiringSoon
+        ),
+        addEntry(
+            "LibreLoopCGMManager",
+            "sensorExpiry.warning2h",
+            .critical,
+            "Sensor Expires in 2 Hours",
+            "Sensor",
+            .cgmExpiringSoon
+        ),
+        addEntry(
+            "LibreLoopCGMManager",
+            "sensorExpiry.sessionEnded",
+            .critical,
+            "Sensor Session Ended",
+            "Sensor",
+            .cgmExpired
+        ),
+        addEntry(
+            "LibreLoopCGMManager",
+            "sensorAttention",
+            .critical,
+            "Replace Sensor",
+            "Sensor",
+            .cgmReplacementNeeded
+        ),
+        addEntry(
+            "LibreLoopCGMManager",
+            "reconnectNeedsReScan",
+            .timeSensitive,
+            "Re-scan Sensor",
+            "Connectivity",
+            .cgmReconnectNeeded
+        )
+    ]
+}
+
+// MARK: - Trio internal — Algorithm
+
+private extension AlertCatalogRegistry {
+    /// Trio-internal alerts emitted under `manager = "trio.aps"`. Levels
+    /// match what `APSManager.issueAlertFor…` / `NotLoopingMonitor` set at
+    /// construction; the catalog presence is what lets these surface in the
+    /// Device Alarms screen alongside pump and CGM entries.
+    /// `loop.notActive` matches `NotLoopingMonitor.alertID`; other slugs
+    /// come from `TrioAlertCategory.alertIdentifier`.
+    static let trioAlgorithmEntries: [Alert.CatalogEntry] = [
+        addEntry("trio.aps", "loop.notActive", .critical, "Trio Not Looping", "Algorithm", .notLooping),
+        addEntry("trio.aps", "algorithmError", .active, "Algorithm Error", "Algorithm", .algorithmError),
+        addEntry("trio.aps", "glucoseDataStale", .timeSensitive, "Glucose Data Stale", "Algorithm", .glucoseDataStale)
+    ]
+}
+
 // MARK: - Helpers
 
 private extension AlertCatalogRegistry {

+ 18 - 0
Trio/Sources/Services/Alerts/AlertCatalogVendor.swift

@@ -33,6 +33,17 @@ extension Alert {
         case pairingFailed
         case userBloodGlucoseReminder
         case basalProfileMismatch
+        // CGM-side concepts. Surface through `TrioAlertManager` via
+        // `PluginSource.issueAlert(_:)`.
+        case cgmExpiringSoon
+        case cgmExpired
+        case cgmReplacementNeeded
+        case cgmReconnectNeeded
+        // Trio-internal "Algorithm" concepts. Emitted by `APSManager` /
+        // `NotLoopingMonitor` under the `trio.aps` manager identifier.
+        case notLooping
+        case algorithmError
+        case glucoseDataStale
         /// Plugin-unique alarm that doesn't merge with anything else.
         case unspecified
 
@@ -57,6 +68,13 @@ extension Alert {
             case .pairingFailed: return String(localized: "Pairing Failed")
             case .userBloodGlucoseReminder: return String(localized: "Blood Glucose Reminder")
             case .basalProfileMismatch: return String(localized: "Basal Profile Mismatch")
+            case .cgmExpiringSoon: return String(localized: "CGM Sensor Expires Soon")
+            case .cgmExpired: return String(localized: "CGM Sensor Expired")
+            case .cgmReplacementNeeded: return String(localized: "Replace CGM Sensor")
+            case .cgmReconnectNeeded: return String(localized: "CGM Reconnect Required")
+            case .notLooping: return String(localized: "Trio Not Looping")
+            case .algorithmError: return String(localized: "Algorithm Error")
+            case .glucoseDataStale: return String(localized: "Glucose Data Stale")
             case .unspecified: return ""
             }
         }

+ 1 - 1
Trio/Sources/Services/Alerts/NotLoopingMonitor.swift

@@ -11,7 +11,7 @@ import Swinject
 ///
 /// Replaces the legacy `scheduleMissingLoopNotifiactions` direct-UN path
 /// in `BaseUserNotificationsManager`. The alert now flows through
-/// `TrioAlertManager` and inherits tier config from Pump Alarms
+/// `TrioAlertManager` and inherits tier config from Device Alarms
 /// (Critical tier by default).
 final class NotLoopingMonitor: Injectable {
     @Injected() private var apsManager: APSManager!

+ 43 - 0
Trio/Sources/Services/Alerts/TrioAlertManager.swift

@@ -2,6 +2,7 @@ import Combine
 import Foundation
 import LoopKit
 import Swinject
+import UIKit
 import UserNotifications
 
 protocol TrioAlertManager: AnyObject {
@@ -84,6 +85,39 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         if let until = persistedSnoozeUntil, until > Date() {
             muter.mute(for: until.timeIntervalSinceNow)
         }
+
+        // iOS doesn't fire `didReceive` on swipe for critical UNs, so we
+        // can't rely on the callback for alerts that override Silence & DnD.
+        // On foreground, mirror iOS: any banner whose UN is no longer in the
+        // delivered list gets hidden.
+        Foundation.NotificationCenter.default.addObserver(
+            self,
+            selector: #selector(reconcileBannersWithDeliveredNotifications),
+            name: UIApplication.didBecomeActiveNotification,
+            object: nil
+        )
+    }
+
+    @objc private func reconcileBannersWithDeliveredNotifications() {
+        UNUserNotificationCenter.current().getDeliveredNotifications { [weak self] delivered in
+            guard let self else { return }
+            let deliveredIDs = Set(delivered.compactMap { note -> Alert.Identifier? in
+                let userInfo = note.request.content.userInfo
+                guard
+                    let managerId = userInfo[AlertUserInfoKey.managerIdentifier.rawValue] as? String,
+                    let alertId = userInfo[AlertUserInfoKey.alertIdentifier.rawValue] as? String
+                else { return nil }
+                return Alert.Identifier(managerIdentifier: managerId, alertIdentifier: alertId)
+            })
+            DispatchQueue.main.async {
+                let stale = self.modalScheduler.active
+                    .map(\.identifier)
+                    .filter { !deliveredIDs.contains($0) }
+                for identifier in stale {
+                    self.modalScheduler.unschedule(identifier: identifier)
+                }
+            }
+        }
     }
 
     /// Falls back to in-process AVAudioPlayer for `.critical` alerts on
@@ -207,6 +241,15 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
             let alertId = userInfo[AlertUserInfoKey.alertIdentifier.rawValue] as? String
         else { return }
         let identifier = Alert.Identifier(managerIdentifier: managerId, alertIdentifier: alertId)
+
+        // Swipe = 15-min snooze (mirrors the in-app banner's swipe-up).
+        // Tap and action buttons keep the full-ack path.
+        if response.actionIdentifier == UNNotificationDismissActionIdentifier {
+            modalScheduler.snooze(identifier: identifier, duration: 15 * 60)
+            userNotificationScheduler.unschedule(identifier: identifier)
+            return
+        }
+
         handleAcknowledgement(identifier: identifier)
     }
 

+ 1 - 132
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -12,18 +12,6 @@ protocol UserNotificationsManager {
     @MainActor func applySnooze(for duration: TimeInterval) async
 }
 
-enum NotificationAction: String {
-    static let key = "action"
-
-    case snooze
-    case pumpConfig
-    case none
-}
-
-protocol alertMessageNotificationObserver {
-    func alertMessageNotification(_ message: MessageContent)
-}
-
 // MARK: - SnoozeObserver Protocol
 
 protocol SnoozeObserver {
@@ -32,9 +20,7 @@ protocol SnoozeObserver {
 
 final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
     enum Identifier: String {
-        case glucoseNotification = "Trio.glucoseNotification"
         case carbsRequiredNotification = "Trio.carbsRequiredNotification"
-        case alertMessageNotification = "Trio.alertMessageNotification"
     }
 
     @Injected() var alertPermissionsChecker: AlertPermissionsChecker!
@@ -42,7 +28,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var apsManager: APSManager!
-    @Injected() private var router: Router!
     @Injected() private var trioAlertManager: TrioAlertManager!
 
     @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
@@ -68,7 +53,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 .share()
                 .eraseToAnyPublisher()
 
-        broadcaster.register(alertMessageNotificationObserver.self, observer: self)
         Task { await updateGlucoseBadge() }
         configureNotificationCategories()
         clearLegacyCarbsRequiredNotification()
@@ -210,96 +194,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @MainActor func applySnooze(for duration: TimeInterval) async {
         await trioAlertManager.applySnooze(for: duration)
     }
-
-    private func addRequest(
-        identifier: Identifier,
-        content: UNMutableNotificationContent,
-        deleteOld: Bool = false,
-        trigger: UNNotificationTrigger? = nil,
-        messageType: MessageType = MessageType.other,
-        messageSubtype: MessageSubtype = MessageSubtype.misc,
-        action: NotificationAction = NotificationAction.none
-    ) {
-        let messageCont = MessageContent(
-            content: content.body,
-            type: messageType,
-            subtype: messageSubtype,
-            title: content.title,
-            useAPN: false,
-            trigger: trigger,
-            action: action
-        )
-        var alertIdentifier = identifier.rawValue
-        if identifier == .alertMessageNotification {
-            alertIdentifier += content.body
-        }
-        if deleteOld {
-            DispatchQueue.main.async {
-                self.notificationCenter.removeDeliveredNotifications(withIdentifiers: [alertIdentifier])
-                self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [alertIdentifier])
-            }
-        }
-        if alertPermissionsChecker.notificationsDisabled {
-            router.alertMessage.send(messageCont)
-            return
-        }
-
-        let request = UNNotificationRequest(identifier: alertIdentifier, content: content, trigger: trigger)
-        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
-            self.notificationCenter.add(request) { error in
-                if let error = error {
-                    warning(.service, "Unable to addNotificationRequest", error: error)
-                    return
-                }
-
-                debug(.service, "Sending \(identifier) notification for \(request.content.title)")
-            }
-        }
-    }
-}
-
-extension BaseUserNotificationsManager: alertMessageNotificationObserver {
-    func alertMessageNotification(_ message: MessageContent) {
-        let content = UNMutableNotificationContent()
-        // Pump / algorithm / glucose / carb subtypes used to route to dedicated
-        // UN identifiers — all of those have moved into the unified
-        // `TrioAlertManager` pipeline.
-        let identifier: Identifier = .alertMessageNotification
-
-        if message.title == "" {
-            switch message.type {
-            case .info:
-                content.title = String(localized: "Info", comment: "Info title")
-            case .warning:
-                content.title = String(localized: "Warning", comment: "Warning title")
-            case .error:
-                content.title = String(localized: "Error", comment: "Error title")
-            default:
-                content.title = message.title
-            }
-        } else {
-            content.title = message.title
-        }
-        switch message.action {
-        case .snooze:
-            content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
-        case .pumpConfig:
-            content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue
-        default: break
-        }
-
-        content.body = String(localized: "\(message.content)", comment: "Info message")
-        content.sound = .default
-        addRequest(
-            identifier: identifier,
-            content: content,
-            deleteOld: true,
-            trigger: message.trigger,
-            messageType: message.type,
-            messageSubtype: message.subtype,
-            action: message.action
-        )
-    }
 }
 
 extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
@@ -331,37 +225,12 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
             return
         }
 
-        // Handle quick snooze actions (from notification action buttons)
+        // Handle quick snooze actions (from notification action buttons).
         if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) {
             Task { @MainActor [weak self] in
                 guard let self else { return }
                 await self.applySnooze(for: quickAction.duration)
             }
-            return
-        }
-
-        // Handle other notification actions (e.g., tapping notification body)
-        guard let actionRaw = response.notification.request.content.userInfo[NotificationAction.key] as? String,
-              let action = NotificationAction(rawValue: actionRaw)
-        else { return }
-
-        // Ensure UI operations happen on main thread using Task for consistency
-        Task { @MainActor [weak self] in
-            guard let self = self else { return }
-            switch action {
-            case .snooze:
-                self.router.mainModalScreen.send(.snooze)
-            case .pumpConfig:
-                let messageCont = MessageContent(
-                    content: response.notification.request.content.body,
-                    type: MessageType.other,
-                    subtype: .pump,
-                    useAPN: false,
-                    action: .pumpConfig
-                )
-                self.router.alertMessage.send(messageCont)
-            default: break
-            }
         }
     }
 }

+ 21 - 6
Trio/Sources/Services/WatchManager/GarminManager.swift

@@ -2,6 +2,7 @@ import Combine
 import ConnectIQ
 import CoreData
 import Foundation
+import LoopKit
 import Swinject
 
 // MARK: - GarminManager Protocol
@@ -54,6 +55,7 @@ final class BaseGarminManager: NSObject, GarminManager, Injectable {
     @Injected() private var determinationStorage: DeterminationStorage!
 
     @Injected() private var iobService: IOBService!
+    @Injected() private var trioAlertManager: TrioAlertManager!
 
     /// Persists the user's device list between app launches.
     @Persisted(key: "BaseGarminManager.persistedDevices") private var persistedDevices: [GarminDevice] = []
@@ -962,13 +964,26 @@ extension BaseGarminManager: IQUIOverrideDelegate, IQDeviceEventDelegate, IQAppM
     /// Typically, you would show an alert or prompt the user to install the app from the store.
     func needsToInstallConnectMobile() {
         debug(.apsManager, "Garmin is not available")
-        let messageCont = MessageContent(
-            content: "The app Garmin Connect must be installed to use Trio.\nGo to the App Store to download it.",
-            type: .warning,
-            subtype: .misc,
-            title: "Garmin is not available"
+        let content = Alert.Content(
+            title: String(localized: "Garmin is not available"),
+            body: String(
+                localized:
+                "The app Garmin Connect must be installed to use Trio.\nGo to the App Store to download it."
+            ),
+            acknowledgeActionButtonLabel: String(localized: "OK")
         )
-        router.alertMessage.send(messageCont)
+        let alert = Alert(
+            identifier: Alert.Identifier(
+                managerIdentifier: "trio.garmin",
+                alertIdentifier: "connect.mobile.missing"
+            ),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: .active,
+            sound: nil
+        )
+        trioAlertManager?.issueAlert(alert)
     }
 
     // MARK: - IQDeviceEventDelegate

+ 1 - 1
Trio/Sources/Views/SettingInputSection.swift

@@ -236,7 +236,7 @@ struct SettingInputSection<VerboseHint: View>: View {
         }.padding(.top)
     }
 
-    private func hintSection(
+    public func hintSection(
         miniHint: String,
         shouldDisplayHint: Binding<Bool>,
         verboseHint: VerboseHint,

+ 73 - 0
Trio/Sources/Views/SnoozeAlertsSheetView.swift

@@ -0,0 +1,73 @@
+import SwiftUI
+import Swinject
+
+/// Shared "Snooze All" sheet. Used from Notifications settings and the home
+/// glucose long-press. Wraps `TrioAlertManager.applySnooze` directly — no
+/// router / module hop.
+struct SnoozeAlertsSheetView: View {
+    let resolver: Resolver
+    @Binding var isPresented: Bool
+
+    @State private var snoozeUntilDate: Date = .distantPast
+
+    @Environment(\.colorScheme) var colorScheme
+    @Environment(AppState.self) var appState
+
+    var body: some View {
+        NavigationStack {
+            List {
+                if snoozeUntilDate > Date() {
+                    Section {
+                        HStack {
+                            Image(systemName: "moon.zzz.fill").foregroundStyle(.tint)
+                            Text(String(
+                                format: String(localized: "Snoozed until %@"),
+                                snoozeUntilDate.formatted(date: .omitted, time: .shortened)
+                            ))
+                                .font(.headline)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                Section(footer: Text(
+                    "Pick a duration to mute every Trio alarm. Critical alerts (e.g. occlusion, urgent low) still pierce the snooze."
+                )) {
+                    ForEach(NotificationResponseAction.allCases, id: \.self) { action in
+                        Button {
+                            applySnooze(action.duration)
+                        } label: {
+                            HStack {
+                                Text(action.localizedTitle).foregroundStyle(.primary)
+                                Spacer()
+                                Image(systemName: "chevron.right")
+                                    .foregroundColor(.gray)
+                                    .font(.footnote)
+                            }
+                        }
+                    }
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Snooze Alerts")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Close") { isPresented = false }
+                }
+            }
+            .onAppear {
+                snoozeUntilDate = UserDefaults.standard
+                    .object(forKey: "UserNotificationsManager.snoozeUntilDate") as? Date ?? .distantPast
+            }
+        }
+    }
+
+    private func applySnooze(_ duration: TimeInterval) {
+        let trioAlertManager = resolver.resolve(TrioAlertManager.self)
+        Task { @MainActor in
+            await trioAlertManager?.applySnooze(for: duration)
+            snoozeUntilDate = Date().addingTimeInterval(duration)
+            isPresented = false
+        }
+    }
+}

+ 56 - 3
TrioTests/AlertCatalogRegistryEntriesTests.swift

@@ -33,7 +33,11 @@ import Testing
             ("Medtrum", "com.nightscout.medtrumkit.patch-occlussion"), // double-s (current MedtrumKit)
             ("Medtrum", "com.nightscout.medtrumkit.patch-occlusion"), // single-s (future MedtrumKit)
             ("Medtrum", "com.nightscout.medtrumkit.patch-fault"),
-            ("Medtrum", "com.nightscout.medtrumkit.patch-empty")
+            ("Medtrum", "com.nightscout.medtrumkit.patch-empty"),
+            ("LibreLoopCGMManager", "sensorExpiry.warning2h"),
+            ("LibreLoopCGMManager", "sensorExpiry.sessionEnded"),
+            ("LibreLoopCGMManager", "sensorAttention"),
+            ("trio.aps", "loop.notActive")
         ]
     ) func criticalEntries(manager: String, alertID: String) {
         #expect(AlertCatalogRegistry.lookup(id(manager, alertID))?.interruptionLevel == .critical)
@@ -49,7 +53,10 @@ import Testing
             ("Dana", "lowBattery"),
             ("Dana", "unknown"),
             ("Medtrum", "com.nightscout.medtrumkit.reservoir-low"),
-            ("Medtrum", "com.nightscout.medtrumkit.patch-daily-limit")
+            ("Medtrum", "com.nightscout.medtrumkit.patch-daily-limit"),
+            ("LibreLoopCGMManager", "sensorExpiry.warning24h"),
+            ("LibreLoopCGMManager", "reconnectNeedsReScan"),
+            ("trio.aps", "glucoseDataStale")
         ]
     ) func timeSensitiveEntries(manager: String, alertID: String) {
         #expect(AlertCatalogRegistry.lookup(id(manager, alertID))?.interruptionLevel == .timeSensitive)
@@ -62,7 +69,8 @@ import Testing
         arguments: [
             ("Omni", "userPodExpiration"),
             ("Dana", "basalCompare"),
-            ("Medtrum", "com.nightscout.medtrumkit.patch-expired")
+            ("Medtrum", "com.nightscout.medtrumkit.patch-expired"),
+            ("trio.aps", "algorithmError")
         ]
     ) func activeEntries(manager: String, alertID: String) {
         #expect(AlertCatalogRegistry.lookup(id(manager, alertID))?.interruptionLevel == .active)
@@ -127,4 +135,49 @@ import Testing
         let keys = Set(AlertCatalogRegistry.entries.map(\.identifier))
         #expect(keys.count == AlertCatalogRegistry.entries.count)
     }
+
+    // MARK: - I: LibreLoop CGM entries map to CGM-side concepts
+
+    @Test(
+        "LibreLoop sensor-expiry entries collapse to .cgmExpiringSoon / .cgmExpired",
+        arguments: [
+            ("sensorExpiry.warning24h", Alert.CatalogConcept.cgmExpiringSoon),
+            ("sensorExpiry.warning2h", Alert.CatalogConcept.cgmExpiringSoon),
+            ("sensorExpiry.sessionEnded", Alert.CatalogConcept.cgmExpired),
+            ("sensorAttention", Alert.CatalogConcept.cgmReplacementNeeded),
+            ("reconnectNeedsReScan", Alert.CatalogConcept.cgmReconnectNeeded)
+        ]
+    ) func libreLoopConcepts(alertID: String, expected: Alert.CatalogConcept) {
+        #expect(AlertCatalogRegistry.lookup(id("LibreLoopCGMManager", alertID))?.concept == expected)
+    }
+
+    @Test("LibreLoop expiry + attention entries are categorized under Sensor") func libreLoopSensorCategory() {
+        let sensorIDs = ["sensorExpiry.warning24h", "sensorExpiry.warning2h", "sensorExpiry.sessionEnded", "sensorAttention"]
+        for alertID in sensorIDs {
+            #expect(AlertCatalogRegistry.lookup(id("LibreLoopCGMManager", alertID))?.category == "Sensor")
+        }
+    }
+
+    @Test("LibreLoop reconnect is categorized under Connectivity") func libreLoopConnectivityCategory() {
+        #expect(AlertCatalogRegistry.lookup(id("LibreLoopCGMManager", "reconnectNeedsReScan"))?.category == "Connectivity")
+    }
+
+    // MARK: - J: Trio-internal Algorithm entries
+
+    @Test(
+        "Trio-internal entries map to Algorithm-side concepts",
+        arguments: [
+            ("loop.notActive", Alert.CatalogConcept.notLooping),
+            ("algorithmError", Alert.CatalogConcept.algorithmError),
+            ("glucoseDataStale", Alert.CatalogConcept.glucoseDataStale)
+        ]
+    ) func trioAlgorithmConcepts(alertID: String, expected: Alert.CatalogConcept) {
+        #expect(AlertCatalogRegistry.lookup(id("trio.aps", alertID))?.concept == expected)
+    }
+
+    @Test("Trio-internal entries are categorized under Algorithm") func trioAlgorithmCategory() {
+        for alertID in ["loop.notActive", "algorithmError", "glucoseDataStale"] {
+            #expect(AlertCatalogRegistry.lookup(id("trio.aps", alertID))?.category == "Algorithm")
+        }
+    }
 }