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

Merge pull request #1203 from dnzxy/feat/refactor-notifications+alerts

Refactor Trio Alert/Notification Handling – Prepare O5 and Libre 3/3+ Integration [1/3]
trioneer-dev недель назад: 2
Родитель
Сommit
2214506dc3
100 измененных файлов с 5972 добавлено и 1208 удалено
  1. 309 25
      Trio.xcodeproj/project.pbxproj
  2. BIN
      Trio/Resources/Sounds/alarm.caf
  3. BIN
      Trio/Resources/Sounds/bloom.caf
  4. BIN
      Trio/Resources/Sounds/bloop.caf
  5. BIN
      Trio/Resources/Sounds/bright_alarm.caf
  6. BIN
      Trio/Resources/Sounds/chime.caf
  7. BIN
      Trio/Resources/Sounds/clear_chimes.caf
  8. BIN
      Trio/Resources/Sounds/critical.caf
  9. BIN
      Trio/Resources/Sounds/crying.caf
  10. BIN
      Trio/Resources/Sounds/dings.caf
  11. BIN
      Trio/Resources/Sounds/high_chimes.caf
  12. BIN
      Trio/Resources/Sounds/honk.caf
  13. BIN
      Trio/Resources/Sounds/minimal.caf
  14. BIN
      Trio/Resources/Sounds/mood_synth.caf
  15. BIN
      Trio/Resources/Sounds/simple.caf
  16. BIN
      Trio/Resources/Sounds/spring.caf
  17. BIN
      Trio/Resources/Sounds/synth.caf
  18. BIN
      Trio/Resources/Sounds/trill.caf
  19. BIN
      Trio/Resources/Sounds/urgent_low.caf
  20. 2 0
      Trio/Resources/Trio.entitlements
  21. 151 17
      Trio/Sources/APS/APSManager.swift
  22. 11 45
      Trio/Sources/APS/DeviceDataManager.swift
  23. 3 3
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  24. 3 0
      Trio/Sources/Application/TrioApp.swift
  25. 6 0
      Trio/Sources/Assemblies/ServiceAssembly.swift
  26. 446 3
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  27. 0 2
      Trio/Sources/Models/DecimalPickerSettings.swift
  28. 34 0
      Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift
  29. 33 0
      Trio/Sources/Models/GlucoseAlerts/DayNightOptions.swift
  30. 62 0
      Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift
  31. 63 0
      Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverityConfig.swift
  32. 77 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlert.swift
  33. 63 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlertConfiguration.swift
  34. 103 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift
  35. 0 27
      Trio/Sources/Models/GlucoseNotificationsOption.swift
  36. 10 0
      Trio/Sources/Models/GlucoseSourceKey.swift
  37. 8 8
      Trio/Sources/Models/NotificationIdentifiers.swift
  38. 0 46
      Trio/Sources/Models/TrioSettings.swift
  39. 7 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsDataFlow.swift
  40. 5 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsProvider.swift
  41. 18 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsStateModel.swift
  42. 135 0
      Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift
  43. 7 0
      Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsDataFlow.swift
  44. 5 0
      Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsProvider.swift
  45. 8 0
      Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsStateModel.swift
  46. 65 0
      Trio/Sources/Modules/DeviceAlarms/View/AddDeviceAlarmSheet.swift
  47. 129 0
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift
  48. 192 0
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift
  49. 7 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsDataFlow.swift
  50. 5 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsProvider.swift
  51. 36 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsStateModel.swift
  52. 87 0
      Trio/Sources/Modules/GlucoseAlerts/View/AddGlucoseAlertSheet.swift
  53. 23 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmActiveSection.swift
  54. 168 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift
  55. 54 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmBGSection.swift
  56. 39 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmEnumMenuPicker.swift
  57. 47 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmGramsSection.swift
  58. 44 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmWindowIcon.swift
  59. 191 0
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift
  60. 344 0
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift
  61. 0 5
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsDataFlow.swift
  62. 0 3
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsProvider.swift
  63. 0 53
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift
  64. 0 389
      Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift
  65. 3 0
      Trio/Sources/Modules/Home/HomeProvider.swift
  66. 1 1
      Trio/Sources/Modules/Home/HomeStateModel.swift
  67. 2 2
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift
  68. 1 47
      Trio/Sources/Modules/Main/MainStateModel.swift
  69. 5 1
      Trio/Sources/Modules/Main/View/MainRootView.swift
  70. 0 2
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  71. 13 1
      Trio/Sources/Modules/PumpConfig/PumpConfigDataFlow.swift
  72. 3 1
      Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift
  73. 1 0
      Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift
  74. 35 15
      Trio/Sources/Modules/Settings/SettingItems.swift
  75. 8 2
      Trio/Sources/Modules/Settings/View/Subviews/NotificationsView.swift
  76. 0 55
      Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
  77. 4 2
      Trio/Sources/Modules/Snooze/SnoozeStateModel.swift
  78. 5 2
      Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
  79. 0 24
      Trio/Sources/Router/Router.swift
  80. 9 3
      Trio/Sources/Router/Screen.swift
  81. 187 0
      Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift
  82. 111 0
      Trio/Sources/Services/Alerts/AlertCatalogVendor.swift
  83. 43 0
      Trio/Sources/Services/Alerts/CGMManagerAlertOwnership.swift
  84. 163 0
      Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift
  85. 178 0
      Trio/Sources/Services/Alerts/DeviceAlertsStore.swift
  86. 53 0
      Trio/Sources/Services/Alerts/ForecastedGlucoseEvaluator.swift
  87. 473 0
      Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift
  88. 145 0
      Trio/Sources/Services/Alerts/GlucoseAlertsStore.swift
  89. 86 0
      Trio/Sources/Services/Alerts/NotLoopingMonitor.swift
  90. 171 0
      Trio/Sources/Services/Alerts/TrioAlertCategory.swift
  91. 476 0
      Trio/Sources/Services/Alerts/TrioAlertManager.swift
  92. 449 0
      Trio/Sources/Services/Alerts/TrioModalAlertScheduler.swift
  93. 100 0
      Trio/Sources/Services/Alerts/TrioUserNotificationAlertScheduler.swift
  94. 2 2
      Trio/Sources/Services/ContactImage/ContactImageManager.swift
  95. 1 9
      Trio/Sources/Services/Storage/FileStorage.swift
  96. 54 409
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift
  97. 0 4
      Trio/Sources/Views/SettingInputSection.swift
  98. 130 0
      TrioTests/AlertCatalogRegistryEntriesTests.swift
  99. 60 0
      TrioTests/AlertCatalogRegistryOmniFaultTests.swift
  100. 0 0
      TrioTests/AlertMuterTests.swift

+ 309 - 25
Trio.xcodeproj/project.pbxproj

@@ -10,7 +10,6 @@
 		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 */; };
-		0F7A65FBD2CD8D6477ED4539 /* GlucoseNotificationSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E625985B47742D498CB1681A /* GlucoseNotificationSettingsProvider.swift */; };
 		110AEDE32C5193D200615CC9 /* BolusIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE02C5193D100615CC9 /* BolusIntent.swift */; };
 		110AEDE42C5193D200615CC9 /* BolusIntentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE12C5193D100615CC9 /* BolusIntentRequest.swift */; };
 		110AEDEB2C51A0AE00615CC9 /* ShortcutsConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110AEDE52C51A0AE00615CC9 /* ShortcutsConfigView.swift */; };
@@ -56,7 +55,6 @@
 		1BBB001DAD60F3B8CEA4B1C7 /* ISFEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 505E09DC17A0C3D0AF4B66FE /* ISFEditorStateModel.swift */; };
 		1D845DF2E3324130E1D95E67 /* HistoryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60744C3E9BB3652895C908CC /* HistoryProvider.swift */; };
 		23888883D4EA091C88480FF2 /* TreatmentsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19984D62EFC0035A9E9644D /* TreatmentsProvider.swift */; };
-		3171D2818C7C72CD1584BB5E /* GlucoseNotificationSettingsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2C6489D29ECCCAD78E0721 /* GlucoseNotificationSettingsStateModel.swift */; };
 		320D030F724170A637F06D50 /* (null) in Sources */ = {isa = PBXBuildFile; };
 		3811DE0B25C9D32F00A708ED /* BaseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE0725C9D32E00A708ED /* BaseView.swift */; };
 		3811DE0C25C9D32F00A708ED /* BaseProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3811DE0825C9D32F00A708ED /* BaseProvider.swift */; };
@@ -179,6 +177,50 @@
 		38E87401274F77E400975559 /* CoreNFC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38E873FD274F761800975559 /* CoreNFC.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
 		38E87403274F78C000975559 /* libswiftCoreNFC.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 38E87402274F78C000975559 /* libswiftCoreNFC.tbd */; settings = {ATTRIBUTES = (Weak, ); }; };
 		38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E87407274F9AD000975559 /* UserNotificationsManager.swift */; };
+		BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179212F4E22C100F90001 /* TrioAlertManager.swift */; };
+		CA01000000000000000010C2 /* AlertCatalogVendor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C1 /* AlertCatalogVendor.swift */; };
+		CA01000000000000000010C4 /* AlertCatalogRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C3 /* AlertCatalogRegistry.swift */; };
+		CA01000000000000000010C6 /* CGMManagerAlertOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */; };
+		BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */; };
+		BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; };
+		BD1179322F4E22C100F90001 /* CriticalAlertAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179312F4E22C100F90001 /* CriticalAlertAudioPlayer.swift */; };
+		BD1179342F4E22C100F90001 /* Sounds in Resources */ = {isa = PBXBuildFile; fileRef = BD1179332F4E22C100F90001 /* Sounds */; };
+		BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */; };
+		BD1179402F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */; };
+		BD1179522F4E22C100F90001 /* DayNightOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179512F4E22C100F90001 /* DayNightOptions.swift */; };
+		BD1179542F4E22C100F90001 /* GlucoseAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179532F4E22C100F90001 /* GlucoseAlert.swift */; };
+		BD1179562F4E22C100F90001 /* GlucoseAlertConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179552F4E22C100F90001 /* GlucoseAlertConfiguration.swift */; };
+		BD1179582F4E22C100F90001 /* GlucoseAlertType.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179572F4E22C100F90001 /* GlucoseAlertType.swift */; };
+		BD11795A2F4E22C100F90001 /* GlucoseAlertsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179592F4E22C100F90001 /* GlucoseAlertsStore.swift */; };
+		BD11795C2F4E22C100F90001 /* GlucoseAlertCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11795D2F4E22C100F90001 /* GlucoseAlertCoordinator.swift */; };
+		BD1179632F4E22C100F90001 /* GlucoseAlertsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179642F4E22C100F90001 /* GlucoseAlertsDataFlow.swift */; };
+		BD1179652F4E22C100F90001 /* GlucoseAlertsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179662F4E22C100F90001 /* GlucoseAlertsProvider.swift */; };
+		BD1179672F4E22C100F90001 /* GlucoseAlertsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179682F4E22C100F90001 /* GlucoseAlertsStateModel.swift */; };
+		BD1179692F4E22C100F90001 /* GlucoseAlertsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11796A2F4E22C100F90001 /* GlucoseAlertsRootView.swift */; };
+		BD11796B2F4E22C100F90001 /* GlucoseAlertEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11796C2F4E22C100F90001 /* GlucoseAlertEditorView.swift */; };
+		BD11796D2F4E22C100F90001 /* AddGlucoseAlertSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11796E2F4E22C100F90001 /* AddGlucoseAlertSheet.swift */; };
+		BD1179712F4E22C100F90001 /* AlarmEnumMenuPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179722F4E22C100F90001 /* AlarmEnumMenuPicker.swift */; };
+		BD1179732F4E22C100F90001 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179742F4E22C100F90001 /* AlarmBGSection.swift */; };
+		CA05000000000000000010C2 /* AlarmGramsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA05000000000000000010C1 /* AlarmGramsSection.swift */; };
+		BD1179772F4E22C100F90001 /* AlarmActiveSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179782F4E22C100F90001 /* AlarmActiveSection.swift */; };
+		BD1179792F4E22C100F90001 /* AlarmAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11797A2F4E22C100F90001 /* AlarmAudioSection.swift */; };
+		BD11797B2F4E22C100F90001 /* AlarmSoundCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11797C2F4E22C100F90001 /* AlarmSoundCatalog.swift */; };
+		BD11797D2F4E22C100F90001 /* AlarmWindowIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11797E2F4E22C100F90001 /* AlarmWindowIcon.swift */; };
+		BD1179832F4E22C100F90001 /* DeviceAlertSeverity.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179842F4E22C100F90001 /* DeviceAlertSeverity.swift */; };
+		BD1179852F4E22C100F90001 /* DeviceAlertSeverityConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179862F4E22C100F90001 /* DeviceAlertSeverityConfig.swift */; };
+		BD1179872F4E22C100F90001 /* DeviceAlertsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179882F4E22C100F90001 /* DeviceAlertsStore.swift */; };
+		BD1179892F4E22C100F90001 /* DeviceAlarmsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11798A2F4E22C100F90001 /* DeviceAlarmsDataFlow.swift */; };
+		BD11798B2F4E22C100F90001 /* DeviceAlarmsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11798C2F4E22C100F90001 /* DeviceAlarmsProvider.swift */; };
+		BD11798D2F4E22C100F90001 /* DeviceAlarmsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11798E2F4E22C100F90001 /* DeviceAlarmsStateModel.swift */; };
+		BD11798F2F4E22C100F90001 /* DeviceAlarmsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179902F4E22C100F90001 /* DeviceAlarmsRootView.swift */; };
+		BD1179912F4E22C100F90001 /* DeviceAlarmEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179922F4E22C100F90001 /* DeviceAlarmEditorView.swift */; };
+		BD1179932F4E22C100F90001 /* AddDeviceAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179942F4E22C100F90001 /* AddDeviceAlarmSheet.swift */; };
+		BD1179A02F4E22C100F90001 /* AlarmWindowsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179A12F4E22C100F90001 /* AlarmWindowsDataFlow.swift */; };
+		BD1179A22F4E22C100F90001 /* AlarmWindowsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179A32F4E22C100F90001 /* AlarmWindowsProvider.swift */; };
+		BD1179A42F4E22C100F90001 /* AlarmWindowsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179A52F4E22C100F90001 /* AlarmWindowsStateModel.swift */; };
+		BD1179A62F4E22C100F90001 /* AlarmWindowsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179A72F4E22C100F90001 /* AlarmWindowsRootView.swift */; };
+		BD1179AA2F4E22C100F90001 /* NotLoopingMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179AB2F4E22C100F90001 /* NotLoopingMonitor.swift */; };
+		BD1179AC2F4E22C100F90001 /* GlucoseSourceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179AD2F4E22C100F90001 /* GlucoseSourceKey.swift */; };
 		38E989DD25F5021400C0CED0 /* PumpStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E989DC25F5021400C0CED0 /* PumpStatus.swift */; };
 		38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1B25F52C9300C0CED0 /* Signpost.swift */; };
 		38E98A2425F52C9300C0CED0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1C25F52C9300C0CED0 /* Logger.swift */; };
@@ -195,6 +237,23 @@
 		38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */; };
 		38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3D525E8FDF40078B0D1 /* MD5.swift */; };
 		38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */; };
+		BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A002 /* AlertMuterTests.swift */; };
+		BD11A001000000000000A013 /* TandemKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A013 /* TandemKitAlertEmissionTests.swift */; };
+		BD11A001000000000000A012 /* RileyLinkKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A012 /* RileyLinkKitAlertEmissionTests.swift */; };
+		BD11A001000000000000A011 /* OmnipodKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A011 /* OmnipodKitAlertEmissionTests.swift */; };
+		BD11A001000000000000A010 /* MinimedKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A010 /* MinimedKitAlertEmissionTests.swift */; };
+		BD11A001000000000000A00F /* MedtrumKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00F /* MedtrumKitAlertEmissionTests.swift */; };
+		BD11A001000000000000A00E /* DanaKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00E /* DanaKitAlertEmissionTests.swift */; };
+		BD11A001000000000000A00D /* AlertCatalogRegistryEntriesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.swift */; };
+		BD11A001000000000000A00C /* NotLoopingMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */; };
+		BD11A001000000000000A00B /* TrioModalAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */; };
+		BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */; };
+		CA06000000000000000010C2 /* GlucoseAlertsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA06000000000000000010C1 /* GlucoseAlertsStoreTests.swift */; };
+		BD11A001000000000000A009 /* TrioAlertCategoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */; };
+		BD11A001000000000000A008 /* GlucoseAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A008 /* GlucoseAlertTests.swift */; };
+		BD11A001000000000000A007 /* GlucoseAlertConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A007 /* GlucoseAlertConfigurationTests.swift */; };
+		BD11A001000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift */; };
+		BD11A001000000000000A003 /* DeviceAlertsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A003 /* DeviceAlertsStoreTests.swift */; };
 		38FCF3FD25E997A80078B0D1 /* PumpHistoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */; };
 		38FE826A25CC82DB001FF17A /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FE826925CC82DB001FF17A /* NetworkService.swift */; };
 		38FE826D25CC8461001FF17A /* NightscoutAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FE826C25CC8461001FF17A /* NightscoutAPI.swift */; };
@@ -388,7 +447,6 @@
 		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 */; };
-		715120D22D3C2BB4005D9FB6 /* GlucoseNotificationsOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 715120D12D3C2B84005D9FB6 /* GlucoseNotificationsOption.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 */; };
@@ -478,6 +536,7 @@
 		BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; };
 		BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */; };
 		BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */; };
+		CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */; };
 		BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */; };
 		BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE52D260D5800035F34 /* WatchState.swift */; };
 		BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EFC2D261BF200035F34 /* WatchState.swift */; };
@@ -535,7 +594,6 @@
 		CA370FC152BC98B3D1832968 /* BasalProfileEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF8BCB0C37DEB5EC377B9612 /* BasalProfileEditorRootView.swift */; };
 		CC6C406E2ACDD69E009B8058 /* RawFetchedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6C406D2ACDD69E009B8058 /* RawFetchedProfile.swift */; };
 		CC76E9512BD4812E008BEB61 /* Forecast+helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC76E9502BD4812E008BEB61 /* Forecast+helper.swift */; };
-		CD78BB94E43B249D60CC1A1B /* GlucoseNotificationSettingsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22963BD06A9C83959D4914E4 /* GlucoseNotificationSettingsRootView.swift */; };
 		CE1856F52ADC4858007E39C7 /* AddCarbPresetIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1856F42ADC4858007E39C7 /* AddCarbPresetIntent.swift */; };
 		CE1856F72ADC4869007E39C7 /* CarbPresetIntentRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1856F62ADC4869007E39C7 /* CarbPresetIntentRequest.swift */; };
 		CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */; };
@@ -812,7 +870,6 @@
 		E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */; };
 		E13B7DAB2A435F57066AF02E /* TargetsEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36F58DDD71F0E795464FA3F0 /* TargetsEditorStateModel.swift */; };
 		E39E418C56A5A46B61D960EE /* ConfigEditorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D5B4F8B4194BB7E260EF251 /* ConfigEditorStateModel.swift */; };
-		E3A08AAE59538BC8A8ABE477 /* GlucoseNotificationSettingsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3260468377DA9DB4DEE9AF6D /* GlucoseNotificationSettingsDataFlow.swift */; };
 		E592A3702CEEC01E009A472C /* ContactTrickEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A36F2CEEC01E009A472C /* ContactTrickEntry.swift */; };
 		E592A3772CEEC038009A472C /* ContactImageStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A3752CEEC038009A472C /* ContactImageStateModel.swift */; };
 		E592A3782CEEC038009A472C /* ContactImageDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E592A3732CEEC038009A472C /* ContactImageDataFlow.swift */; };
@@ -999,10 +1056,8 @@
 		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>"; };
-		22963BD06A9C83959D4914E4 /* GlucoseNotificationSettingsRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationSettingsRootView.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>"; };
-		3260468377DA9DB4DEE9AF6D /* GlucoseNotificationSettingsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationSettingsDataFlow.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>"; };
@@ -1137,6 +1192,50 @@
 		38E873FD274F761800975559 /* CoreNFC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreNFC.framework; path = System/Library/Frameworks/CoreNFC.framework; sourceTree = SDKROOT; };
 		38E87402274F78C000975559 /* libswiftCoreNFC.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libswiftCoreNFC.tbd; path = usr/lib/swift/libswiftCoreNFC.tbd; sourceTree = SDKROOT; };
 		38E87407274F9AD000975559 /* UserNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationsManager.swift; sourceTree = "<group>"; };
+		BD1179212F4E22C100F90001 /* TrioAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertManager.swift; sourceTree = "<group>"; };
+		CA01000000000000000010C1 /* AlertCatalogVendor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogVendor.swift; sourceTree = "<group>"; };
+		CA01000000000000000010C3 /* AlertCatalogRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistry.swift; sourceTree = "<group>"; };
+		CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMManagerAlertOwnership.swift; sourceTree = "<group>"; };
+		BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertScheduler.swift; sourceTree = "<group>"; };
+		BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioUserNotificationAlertScheduler.swift; sourceTree = "<group>"; };
+		BD1179312F4E22C100F90001 /* CriticalAlertAudioPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalAlertAudioPlayer.swift; sourceTree = "<group>"; };
+		BD1179332F4E22C100F90001 /* Sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Sounds; sourceTree = "<group>"; };
+		BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertCategory.swift; sourceTree = "<group>"; };
+		BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastedGlucoseEvaluator.swift; sourceTree = "<group>"; };
+		BD1179512F4E22C100F90001 /* DayNightOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayNightOptions.swift; sourceTree = "<group>"; };
+		BD1179532F4E22C100F90001 /* GlucoseAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlert.swift; sourceTree = "<group>"; };
+		BD1179552F4E22C100F90001 /* GlucoseAlertConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertConfiguration.swift; sourceTree = "<group>"; };
+		BD1179572F4E22C100F90001 /* GlucoseAlertType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertType.swift; sourceTree = "<group>"; };
+		BD1179592F4E22C100F90001 /* GlucoseAlertsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertsStore.swift; sourceTree = "<group>"; };
+		BD11795D2F4E22C100F90001 /* GlucoseAlertCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertCoordinator.swift; sourceTree = "<group>"; };
+		BD1179642F4E22C100F90001 /* GlucoseAlertsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertsDataFlow.swift; sourceTree = "<group>"; };
+		BD1179662F4E22C100F90001 /* GlucoseAlertsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertsProvider.swift; sourceTree = "<group>"; };
+		BD1179682F4E22C100F90001 /* GlucoseAlertsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertsStateModel.swift; sourceTree = "<group>"; };
+		BD11796A2F4E22C100F90001 /* GlucoseAlertsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertsRootView.swift; sourceTree = "<group>"; };
+		BD11796C2F4E22C100F90001 /* GlucoseAlertEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertEditorView.swift; sourceTree = "<group>"; };
+		BD11796E2F4E22C100F90001 /* AddGlucoseAlertSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGlucoseAlertSheet.swift; sourceTree = "<group>"; };
+		BD1179722F4E22C100F90001 /* AlarmEnumMenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmEnumMenuPicker.swift; sourceTree = "<group>"; };
+		BD1179742F4E22C100F90001 /* AlarmBGSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmBGSection.swift; sourceTree = "<group>"; };
+		CA05000000000000000010C1 /* AlarmGramsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmGramsSection.swift; sourceTree = "<group>"; };
+		BD1179782F4E22C100F90001 /* AlarmActiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmActiveSection.swift; sourceTree = "<group>"; };
+		BD11797A2F4E22C100F90001 /* AlarmAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmAudioSection.swift; sourceTree = "<group>"; };
+		BD11797C2F4E22C100F90001 /* AlarmSoundCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSoundCatalog.swift; sourceTree = "<group>"; };
+		BD11797E2F4E22C100F90001 /* AlarmWindowIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowIcon.swift; sourceTree = "<group>"; };
+		BD1179842F4E22C100F90001 /* DeviceAlertSeverity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlertSeverity.swift; sourceTree = "<group>"; };
+		BD1179862F4E22C100F90001 /* DeviceAlertSeverityConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlertSeverityConfig.swift; sourceTree = "<group>"; };
+		BD1179882F4E22C100F90001 /* DeviceAlertsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlertsStore.swift; sourceTree = "<group>"; };
+		BD11798A2F4E22C100F90001 /* DeviceAlarmsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsDataFlow.swift; sourceTree = "<group>"; };
+		BD11798C2F4E22C100F90001 /* DeviceAlarmsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsProvider.swift; sourceTree = "<group>"; };
+		BD11798E2F4E22C100F90001 /* DeviceAlarmsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsStateModel.swift; sourceTree = "<group>"; };
+		BD1179902F4E22C100F90001 /* DeviceAlarmsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsRootView.swift; sourceTree = "<group>"; };
+		BD1179922F4E22C100F90001 /* DeviceAlarmEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmEditorView.swift; sourceTree = "<group>"; };
+		BD1179942F4E22C100F90001 /* AddDeviceAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddDeviceAlarmSheet.swift; sourceTree = "<group>"; };
+		BD1179A12F4E22C100F90001 /* AlarmWindowsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowsDataFlow.swift; sourceTree = "<group>"; };
+		BD1179A32F4E22C100F90001 /* AlarmWindowsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowsProvider.swift; sourceTree = "<group>"; };
+		BD1179A52F4E22C100F90001 /* AlarmWindowsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowsStateModel.swift; sourceTree = "<group>"; };
+		BD1179A72F4E22C100F90001 /* AlarmWindowsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowsRootView.swift; sourceTree = "<group>"; };
+		BD1179AB2F4E22C100F90001 /* NotLoopingMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotLoopingMonitor.swift; sourceTree = "<group>"; };
+		BD1179AD2F4E22C100F90001 /* GlucoseSourceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSourceKey.swift; sourceTree = "<group>"; };
 		38E989DC25F5021400C0CED0 /* PumpStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatus.swift; sourceTree = "<group>"; };
 		38E98A1B25F52C9300C0CED0 /* Signpost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Signpost.swift; sourceTree = "<group>"; };
 		38E98A1C25F52C9300C0CED0 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
@@ -1156,6 +1255,23 @@
 		38FCF3ED25E9028E0078B0D1 /* TrioTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TrioTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		38FCF3F125E9028E0078B0D1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStorageTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A002 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A013 /* TandemKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TandemKitAlertEmissionTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A012 /* RileyLinkKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkKitAlertEmissionTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A011 /* OmnipodKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnipodKitAlertEmissionTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A010 /* MinimedKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MinimedKitAlertEmissionTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A00F /* MedtrumKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MedtrumKitAlertEmissionTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A00E /* DanaKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DanaKitAlertEmissionTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistryEntriesTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotLoopingMonitorTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertSchedulerTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertCoordinatorTests.swift; sourceTree = "<group>"; };
+		CA06000000000000000010C1 /* GlucoseAlertsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertsStoreTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertCategoryTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A008 /* GlucoseAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A007 /* GlucoseAlertConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertConfigurationTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastedGlucoseEvaluatorTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A003 /* DeviceAlertsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlertsStoreTests.swift; sourceTree = "<group>"; };
 		38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistoryStorage.swift; sourceTree = "<group>"; };
 		38FE826925CC82DB001FF17A /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = "<group>"; };
 		38FE826C25CC8461001FF17A /* NightscoutAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutAPI.swift; sourceTree = "<group>"; };
@@ -1327,7 +1443,6 @@
 		6B1A8D252B14D91700E76752 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		6B1A8D2D2B156EEF00E76752 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
 		6BCF84DC2B16843A003AD46E /* LiveActitiyAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActitiyAttributes.swift; sourceTree = "<group>"; };
-		715120D12D3C2B84005D9FB6 /* GlucoseNotificationsOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationsOption.swift; sourceTree = "<group>"; };
 		71D44AAA2CA5F5EA0036EE9E /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = "<group>"; };
 		79BDA519C9B890FD9A5DFCF3 /* ISFEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ISFEditorDataFlow.swift; sourceTree = "<group>"; };
 		7E22146D3DF4853786C78132 /* CarbRatioEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarbRatioEditorDataFlow.swift; sourceTree = "<group>"; };
@@ -1413,6 +1528,7 @@
 		BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolTherapySettingsTests.swift; sourceTree = "<group>"; };
+		CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistryOmniFaultTests.swift; sourceTree = "<group>"; };
 		BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleWatchManager.swift; sourceTree = "<group>"; };
 		BDA25EE52D260D5800035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
 		BDA25EFC2D261BF200035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = "<group>"; };
@@ -1529,7 +1645,6 @@
 		CEF1ED6A2D58FB4600FAF41E /* CGMOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMOptions.swift; sourceTree = "<group>"; };
 		CFCFE0781F9074C2917890E8 /* ManualTempBasalStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ManualTempBasalStateModel.swift; sourceTree = "<group>"; };
 		D0BDC6993C1087310EDFC428 /* CarbRatioEditorRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CarbRatioEditorRootView.swift; sourceTree = "<group>"; };
-		DC2C6489D29ECCCAD78E0721 /* GlucoseNotificationSettingsStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationSettingsStateModel.swift; sourceTree = "<group>"; };
 		DD09D47A2C5986D1003FEA5D /* CalendarEventSettingsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventSettingsDataFlow.swift; sourceTree = "<group>"; };
 		DD09D47C2C5986DA003FEA5D /* CalendarEventSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventSettingsProvider.swift; sourceTree = "<group>"; };
 		DD09D47E2C5986E5003FEA5D /* CalendarEventSettingsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarEventSettingsStateModel.swift; sourceTree = "<group>"; };
@@ -1757,7 +1872,6 @@
 		E592A3732CEEC038009A472C /* ContactImageDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageDataFlow.swift; sourceTree = "<group>"; };
 		E592A3742CEEC038009A472C /* ContactImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageProvider.swift; sourceTree = "<group>"; };
 		E592A3752CEEC038009A472C /* ContactImageStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageStateModel.swift; sourceTree = "<group>"; };
-		E625985B47742D498CB1681A /* GlucoseNotificationSettingsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlucoseNotificationSettingsProvider.swift; sourceTree = "<group>"; };
 		B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsSearchTests.swift; sourceTree = "<group>"; };
 		F816825D28DB441200054060 /* HeartBeatManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartBeatManager.swift; sourceTree = "<group>"; };
 		F816825F28DB441800054060 /* BluetoothTransmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothTransmitter.swift; sourceTree = "<group>"; };
@@ -2133,7 +2247,9 @@
 				195D80B22AF696EE00D25097 /* DynamicSettings */,
 				C2AA6CF52E1A734A00BF6C16 /* SettingsExport */,
 				DD17454C2C55CA0200211FAC /* GeneralSettings */,
-				F66B236E00924A05D6A9F9DF /* GlucoseNotificationSettings */,
+				BD1179602F4E22C100F90001 /* GlucoseAlerts */,
+				BD11799A2F4E22C100F90001 /* DeviceAlarms */,
+				BD1179A82F4E22C100F90001 /* AlarmWindows */,
 				F90692CD274B99850037068D /* HealthKit */,
 				3811DE2725C9D49500A708ED /* Home */,
 				19E1F7E629D0828B005C8D20 /* IconConfig */,
@@ -2270,6 +2386,7 @@
 		3811DE9125C9D88200A708ED /* Services */ = {
 			isa = PBXGroup;
 			children = (
+				BD1179262F4E22C100F90001 /* Alerts */,
 				3811DE9225C9D88200A708ED /* Appearance */,
 				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
@@ -2294,6 +2411,26 @@
 			path = Services;
 			sourceTree = "<group>";
 		};
+		BD1179262F4E22C100F90001 /* Alerts */ = {
+			isa = PBXGroup;
+			children = (
+				BD1179312F4E22C100F90001 /* CriticalAlertAudioPlayer.swift */,
+				BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */,
+				BD11795D2F4E22C100F90001 /* GlucoseAlertCoordinator.swift */,
+				BD1179592F4E22C100F90001 /* GlucoseAlertsStore.swift */,
+				BD1179882F4E22C100F90001 /* DeviceAlertsStore.swift */,
+				BD1179AB2F4E22C100F90001 /* NotLoopingMonitor.swift */,
+				BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */,
+				BD1179212F4E22C100F90001 /* TrioAlertManager.swift */,
+				BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */,
+				BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */,
+				CA01000000000000000010C1 /* AlertCatalogVendor.swift */,
+				CA01000000000000000010C3 /* AlertCatalogRegistry.swift */,
+				CA01000000000000000010C5 /* CGMManagerAlertOwnership.swift */,
+			);
+			path = Alerts;
+			sourceTree = "<group>";
+		};
 		DD7E1E30000000000000000A /* Telemetry */ = {
 			isa = PBXGroup;
 			children = (
@@ -2389,6 +2526,7 @@
 			children = (
 				388E597125AD9CF10019842D /* json */,
 				388E596E25AD96040019842D /* javascript */,
+				BD1179332F4E22C100F90001 /* Sounds */,
 				3811DEC725C9DA7300A708ED /* Trio.entitlements */,
 				388E596425AD948E0019842D /* Info.plist */,
 				8A91342B2D63D9A2007F8874 /* InfoPlist.xcstrings */,
@@ -2618,9 +2756,24 @@
 			path = OpenAPS;
 			sourceTree = "<group>";
 		};
+		BD11795B2F4E22C100F90001 /* GlucoseAlerts */ = {
+			isa = PBXGroup;
+			children = (
+				BD11797C2F4E22C100F90001 /* AlarmSoundCatalog.swift */,
+				BD1179512F4E22C100F90001 /* DayNightOptions.swift */,
+				BD1179532F4E22C100F90001 /* GlucoseAlert.swift */,
+				BD1179552F4E22C100F90001 /* GlucoseAlertConfiguration.swift */,
+				BD1179572F4E22C100F90001 /* GlucoseAlertType.swift */,
+				BD1179842F4E22C100F90001 /* DeviceAlertSeverity.swift */,
+				BD1179862F4E22C100F90001 /* DeviceAlertSeverityConfig.swift */,
+			);
+			path = GlucoseAlerts;
+			sourceTree = "<group>";
+		};
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				BD11795B2F4E22C100F90001 /* GlucoseAlerts */,
 				49C782A62F73D9870062B0DD /* AlertEntry.swift */,
 				DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */,
 				3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */,
@@ -2639,7 +2792,7 @@
 				49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */,
 				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
 				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
-				715120D12D3C2B84005D9FB6 /* GlucoseNotificationsOption.swift */,
+				BD1179AD2F4E22C100F90001 /* GlucoseSourceKey.swift */,
 				E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */,
 				382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */,
 				38887CCD25F5725200944304 /* IOBEntry.swift */,
@@ -2875,12 +3028,30 @@
 				CA02000000000000000010C1 /* DeliveryLimitsSyncTests.swift */,
 				3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
+				BD11A000000000000000A002 /* AlertMuterTests.swift */,
+				BD11A000000000000000A013 /* TandemKitAlertEmissionTests.swift */,
+				BD11A000000000000000A012 /* RileyLinkKitAlertEmissionTests.swift */,
+				BD11A000000000000000A011 /* OmnipodKitAlertEmissionTests.swift */,
+				BD11A000000000000000A010 /* MinimedKitAlertEmissionTests.swift */,
+				BD11A000000000000000A00F /* MedtrumKitAlertEmissionTests.swift */,
+				BD11A000000000000000A00E /* DanaKitAlertEmissionTests.swift */,
+				BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.swift */,
+				BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */,
+				BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */,
+				BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */,
+				CA06000000000000000010C1 /* GlucoseAlertsStoreTests.swift */,
+				BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */,
+				BD11A000000000000000A008 /* GlucoseAlertTests.swift */,
+				BD11A000000000000000A007 /* GlucoseAlertConfigurationTests.swift */,
+				BD11A000000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift */,
+				BD11A000000000000000A003 /* DeviceAlertsStoreTests.swift */,
 				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */,
 				B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */,
 				BD8FC0532D66186000B95AED /* TestError.swift */,
 				BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */,
+				CA03000000000000000010C1 /* AlertCatalogRegistryOmniFaultTests.swift */,
 			);
 			path = TrioTests;
 			sourceTree = "<group>";
@@ -4207,23 +4378,79 @@
 			path = View;
 			sourceTree = "<group>";
 		};
-		F5DE2E6D7B2133BBD3353DC7 /* View */ = {
+		BD1179602F4E22C100F90001 /* GlucoseAlerts */ = {
+			isa = PBXGroup;
+			children = (
+				BD1179642F4E22C100F90001 /* GlucoseAlertsDataFlow.swift */,
+				BD1179662F4E22C100F90001 /* GlucoseAlertsProvider.swift */,
+				BD1179682F4E22C100F90001 /* GlucoseAlertsStateModel.swift */,
+				BD1179612F4E22C100F90001 /* View */,
+			);
+			path = GlucoseAlerts;
+			sourceTree = "<group>";
+		};
+		BD1179A82F4E22C100F90001 /* AlarmWindows */ = {
+			isa = PBXGroup;
+			children = (
+				BD1179A12F4E22C100F90001 /* AlarmWindowsDataFlow.swift */,
+				BD1179A32F4E22C100F90001 /* AlarmWindowsProvider.swift */,
+				BD1179A52F4E22C100F90001 /* AlarmWindowsStateModel.swift */,
+				BD1179A92F4E22C100F90001 /* View */,
+			);
+			path = AlarmWindows;
+			sourceTree = "<group>";
+		};
+		BD1179A92F4E22C100F90001 /* View */ = {
+			isa = PBXGroup;
+			children = (
+				BD1179A72F4E22C100F90001 /* AlarmWindowsRootView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
+		BD11799A2F4E22C100F90001 /* DeviceAlarms */ = {
+			isa = PBXGroup;
+			children = (
+				BD11798A2F4E22C100F90001 /* DeviceAlarmsDataFlow.swift */,
+				BD11798C2F4E22C100F90001 /* DeviceAlarmsProvider.swift */,
+				BD11798E2F4E22C100F90001 /* DeviceAlarmsStateModel.swift */,
+				BD11799B2F4E22C100F90001 /* View */,
+			);
+			path = DeviceAlarms;
+			sourceTree = "<group>";
+		};
+		BD11799B2F4E22C100F90001 /* View */ = {
+			isa = PBXGroup;
+			children = (
+				BD1179942F4E22C100F90001 /* AddDeviceAlarmSheet.swift */,
+				BD1179922F4E22C100F90001 /* DeviceAlarmEditorView.swift */,
+				BD1179902F4E22C100F90001 /* DeviceAlarmsRootView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
+		BD1179612F4E22C100F90001 /* View */ = {
 			isa = PBXGroup;
 			children = (
-				22963BD06A9C83959D4914E4 /* GlucoseNotificationSettingsRootView.swift */,
+				BD11796E2F4E22C100F90001 /* AddGlucoseAlertSheet.swift */,
+				BD11796C2F4E22C100F90001 /* GlucoseAlertEditorView.swift */,
+				BD11796A2F4E22C100F90001 /* GlucoseAlertsRootView.swift */,
+				BD1179622F4E22C100F90001 /* Components */,
 			);
 			path = View;
 			sourceTree = "<group>";
 		};
-		F66B236E00924A05D6A9F9DF /* GlucoseNotificationSettings */ = {
+		BD1179622F4E22C100F90001 /* Components */ = {
 			isa = PBXGroup;
 			children = (
-				3260468377DA9DB4DEE9AF6D /* GlucoseNotificationSettingsDataFlow.swift */,
-				E625985B47742D498CB1681A /* GlucoseNotificationSettingsProvider.swift */,
-				DC2C6489D29ECCCAD78E0721 /* GlucoseNotificationSettingsStateModel.swift */,
-				F5DE2E6D7B2133BBD3353DC7 /* View */,
+				BD1179782F4E22C100F90001 /* AlarmActiveSection.swift */,
+				BD11797A2F4E22C100F90001 /* AlarmAudioSection.swift */,
+				BD1179742F4E22C100F90001 /* AlarmBGSection.swift */,
+				CA05000000000000000010C1 /* AlarmGramsSection.swift */,
+				BD1179722F4E22C100F90001 /* AlarmEnumMenuPicker.swift */,
+				BD11797E2F4E22C100F90001 /* AlarmWindowIcon.swift */,
 			);
-			path = GlucoseNotificationSettings;
+			path = Components;
 			sourceTree = "<group>";
 		};
 		F75CB57ED6971B46F8756083 /* CGMSettings */ = {
@@ -4507,6 +4734,7 @@
 				19DA48E829CD339B00EEA1E7 /* Assets.xcassets in Resources */,
 				8A91342A2D63D9A1007F8874 /* Localizable.xcstrings in Resources */,
 				388E596F25AD96040019842D /* javascript in Resources */,
+				BD1179342F4E22C100F90001 /* Sounds in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};
@@ -4906,6 +5134,49 @@
 				BD249D8C2D42FC2C00412DEB /* GlucoseDistributionChart.swift in Sources */,
 				DDFF20312DB1D15500AB8A96 /* BluetoothPermissionStepView.swift in Sources */,
 				38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */,
+				BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */,
+				CA01000000000000000010C2 /* AlertCatalogVendor.swift in Sources */,
+				CA01000000000000000010C4 /* AlertCatalogRegistry.swift in Sources */,
+				CA01000000000000000010C6 /* CGMManagerAlertOwnership.swift in Sources */,
+				BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */,
+				BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */,
+				BD1179322F4E22C100F90001 /* CriticalAlertAudioPlayer.swift in Sources */,
+				BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */,
+				BD1179402F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift in Sources */,
+				BD1179522F4E22C100F90001 /* DayNightOptions.swift in Sources */,
+				BD1179542F4E22C100F90001 /* GlucoseAlert.swift in Sources */,
+				BD1179562F4E22C100F90001 /* GlucoseAlertConfiguration.swift in Sources */,
+				BD1179582F4E22C100F90001 /* GlucoseAlertType.swift in Sources */,
+				BD11795A2F4E22C100F90001 /* GlucoseAlertsStore.swift in Sources */,
+				BD11795C2F4E22C100F90001 /* GlucoseAlertCoordinator.swift in Sources */,
+				BD1179632F4E22C100F90001 /* GlucoseAlertsDataFlow.swift in Sources */,
+				BD1179652F4E22C100F90001 /* GlucoseAlertsProvider.swift in Sources */,
+				BD1179672F4E22C100F90001 /* GlucoseAlertsStateModel.swift in Sources */,
+				BD1179692F4E22C100F90001 /* GlucoseAlertsRootView.swift in Sources */,
+				BD11796B2F4E22C100F90001 /* GlucoseAlertEditorView.swift in Sources */,
+				BD11796D2F4E22C100F90001 /* AddGlucoseAlertSheet.swift in Sources */,
+				BD1179712F4E22C100F90001 /* AlarmEnumMenuPicker.swift in Sources */,
+				BD1179732F4E22C100F90001 /* AlarmBGSection.swift in Sources */,
+				CA05000000000000000010C2 /* AlarmGramsSection.swift in Sources */,
+				BD1179772F4E22C100F90001 /* AlarmActiveSection.swift in Sources */,
+				BD1179792F4E22C100F90001 /* AlarmAudioSection.swift in Sources */,
+				BD11797B2F4E22C100F90001 /* AlarmSoundCatalog.swift in Sources */,
+				BD11797D2F4E22C100F90001 /* AlarmWindowIcon.swift in Sources */,
+				BD1179832F4E22C100F90001 /* DeviceAlertSeverity.swift in Sources */,
+				BD1179852F4E22C100F90001 /* DeviceAlertSeverityConfig.swift in Sources */,
+				BD1179872F4E22C100F90001 /* DeviceAlertsStore.swift in Sources */,
+				BD1179892F4E22C100F90001 /* DeviceAlarmsDataFlow.swift in Sources */,
+				BD11798B2F4E22C100F90001 /* DeviceAlarmsProvider.swift in Sources */,
+				BD11798D2F4E22C100F90001 /* DeviceAlarmsStateModel.swift in Sources */,
+				BD11798F2F4E22C100F90001 /* DeviceAlarmsRootView.swift in Sources */,
+				BD1179912F4E22C100F90001 /* DeviceAlarmEditorView.swift in Sources */,
+				BD1179932F4E22C100F90001 /* AddDeviceAlarmSheet.swift in Sources */,
+				BD1179A02F4E22C100F90001 /* AlarmWindowsDataFlow.swift in Sources */,
+				BD1179A22F4E22C100F90001 /* AlarmWindowsProvider.swift in Sources */,
+				BD1179A42F4E22C100F90001 /* AlarmWindowsStateModel.swift in Sources */,
+				BD1179A62F4E22C100F90001 /* AlarmWindowsRootView.swift in Sources */,
+				BD1179AA2F4E22C100F90001 /* NotLoopingMonitor.swift in Sources */,
+				BD1179AC2F4E22C100F90001 /* GlucoseSourceKey.swift in Sources */,
 				DD3F1F902D9E153F00DCE7B3 /* NightscoutImportStepView.swift in Sources */,
 				CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */,
 				DD1745372C55B74200211FAC /* AlgorithmSettings.swift in Sources */,
@@ -5155,7 +5426,6 @@
 				BD249D922D42FC5300412DEB /* GlucoseSectorChart.swift in Sources */,
 				23888883D4EA091C88480FF2 /* TreatmentsProvider.swift in Sources */,
 				38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */,
-				715120D22D3C2BB4005D9FB6 /* GlucoseNotificationsOption.swift in Sources */,
 				BB5227A51D9D4377A1A70BA6 /* TempTargetData.swift in Sources */,
 				BDBAACFA2C2D439700370AAE /* OverrideData.swift in Sources */,
 				DD9ECB682CA99F4500AA7C45 /* TrioRemoteControl.swift in Sources */,
@@ -5228,9 +5498,6 @@
 				DD9E6DA22D59A12700514CEC /* MealHistory.swift in Sources */,
 				19E1F7E829D082D0005C8D20 /* IconConfigDataFlow.swift in Sources */,
 				5A2325522BFCBF55003518CA /* NightscoutUploadView.swift in Sources */,
-				E3A08AAE59538BC8A8ABE477 /* GlucoseNotificationSettingsDataFlow.swift in Sources */,
-				0F7A65FBD2CD8D6477ED4539 /* GlucoseNotificationSettingsProvider.swift in Sources */,
-				3171D2818C7C72CD1584BB5E /* GlucoseNotificationSettingsStateModel.swift in Sources */,
 				DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */,
 				DD30B9CA2E062A3400DA677C /* ForecastGenerator.swift in Sources */,
 				DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */,
@@ -5275,7 +5542,6 @@
 				DD9ECB742CA9A0C300AA7C45 /* RemoteControlConfig.swift in Sources */,
 				DD4A00212DAEEED800AB7387 /* OnboardingView+AlgorithmUtil.swift in Sources */,
 				DDE179712C910127003CDDB7 /* OverrideStored+CoreDataProperties.swift in Sources */,
-				CD78BB94E43B249D60CC1A1B /* GlucoseNotificationSettingsRootView.swift in Sources */,
 				CE7CA3502A064973004BE681 /* CancelTempPresetIntent.swift in Sources */,
 				6B1F539F9FF75646D1606066 /* SnoozeDataFlow.swift in Sources */,
 				6FFAE524D1D9C262F2407CAE /* SnoozeProvider.swift in Sources */,
@@ -5347,6 +5613,23 @@
 				3B2CE68B2E24ADF7005EF782 /* IobGenerateTests.swift in Sources */,
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
+				BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */,
+				BD11A001000000000000A013 /* TandemKitAlertEmissionTests.swift in Sources */,
+				BD11A001000000000000A012 /* RileyLinkKitAlertEmissionTests.swift in Sources */,
+				BD11A001000000000000A011 /* OmnipodKitAlertEmissionTests.swift in Sources */,
+				BD11A001000000000000A010 /* MinimedKitAlertEmissionTests.swift in Sources */,
+				BD11A001000000000000A00F /* MedtrumKitAlertEmissionTests.swift in Sources */,
+				BD11A001000000000000A00E /* DanaKitAlertEmissionTests.swift in Sources */,
+				BD11A001000000000000A00D /* AlertCatalogRegistryEntriesTests.swift in Sources */,
+				BD11A001000000000000A00C /* NotLoopingMonitorTests.swift in Sources */,
+				BD11A001000000000000A00B /* TrioModalAlertSchedulerTests.swift in Sources */,
+				BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */,
+				CA06000000000000000010C2 /* GlucoseAlertsStoreTests.swift in Sources */,
+				BD11A001000000000000A009 /* TrioAlertCategoryTests.swift in Sources */,
+				BD11A001000000000000A008 /* GlucoseAlertTests.swift in Sources */,
+				BD11A001000000000000A007 /* GlucoseAlertConfigurationTests.swift in Sources */,
+				BD11A001000000000000A006 /* ForecastedGlucoseEvaluatorTests.swift in Sources */,
+				BD11A001000000000000A003 /* DeviceAlertsStoreTests.swift in Sources */,
 				3B8221B22E5882E300585156 /* DetermineBasalEarlyExitTests.swift in Sources */,
 				3BA643EA2ED9FAD8007BC31F /* DetermineBasalAggressiveDosingTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
@@ -5355,6 +5638,7 @@
 				41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */,
 				B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */,
 				BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */,
+				CA03000000000000000010C2 /* AlertCatalogRegistryOmniFaultTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

BIN
Trio/Resources/Sounds/alarm.caf


BIN
Trio/Resources/Sounds/bloom.caf


BIN
Trio/Resources/Sounds/bloop.caf


BIN
Trio/Resources/Sounds/bright_alarm.caf


BIN
Trio/Resources/Sounds/chime.caf


BIN
Trio/Resources/Sounds/clear_chimes.caf


BIN
Trio/Resources/Sounds/critical.caf


BIN
Trio/Resources/Sounds/crying.caf


BIN
Trio/Resources/Sounds/dings.caf


BIN
Trio/Resources/Sounds/high_chimes.caf


BIN
Trio/Resources/Sounds/honk.caf


BIN
Trio/Resources/Sounds/minimal.caf


BIN
Trio/Resources/Sounds/mood_synth.caf


BIN
Trio/Resources/Sounds/simple.caf


BIN
Trio/Resources/Sounds/spring.caf


BIN
Trio/Resources/Sounds/synth.caf


BIN
Trio/Resources/Sounds/trill.caf


BIN
Trio/Resources/Sounds/urgent_low.caf


+ 2 - 0
Trio/Resources/Trio.entitlements

@@ -14,6 +14,8 @@
 	<array>
 		<string>TAG</string>
 	</array>
+	<key>com.apple.developer.usernotifications.time-sensitive</key>
+	<true/>
 	<key>com.apple.security.application-groups</key>
 	<array>
 		<string>$(APP_GROUP_ID)</string>

+ 151 - 17
Trio/Sources/APS/APSManager.swift

@@ -8,6 +8,11 @@ import Swinject
 
 protocol APSManager {
     func heartbeat(date: Date)
+    /// Mark the next loop attempt as user-initiated (e.g. force-loop button).
+    /// Surfaces transient errors immediately instead of waiting for the
+    /// usual dwell threshold — when the user explicitly asks for a loop,
+    /// they want feedback even if the underlying error is "transient".
+    func markNextLoopUserInitiated()
     func enactBolus(amount: Double, isSMB: Bool, callback: ((Bool, String) -> Void)?) async
     var pumpManager: PumpManagerUI? { get set }
     var bluetoothManager: BluetoothStateManager? { get }
@@ -36,6 +41,13 @@ protocol APSManager {
     var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
 }
 
+/// Notified after a bolus-related failure so observing UI (e.g. the
+/// treatment screen's bolus state) can clean up state. Broadcast by
+/// `APSManager` from `enactBolus` / `cancelBolus` error paths.
+protocol BolusFailureObserver {
+    func bolusDidFail()
+}
+
 enum APSError: LocalizedError {
     case pumpError(Error)
     case invalidPumpState(message: String)
@@ -57,17 +69,6 @@ enum APSError: LocalizedError {
             return String(localized: "Manual Temporary Basal Rate (\(message)). Looping suspended.")
         }
     }
-
-    static func pumpErrorMatches(message: String) -> Bool {
-        message.contains(String(localized: "Pump Error"))
-    }
-
-    static func pumpWarningMatches(message: String) -> Bool {
-        message.contains(String(localized: "Invalid Pump State")) || message
-            .contains("PumpMessage") || message
-            .contains("PumpOpsError") || message.contains("RileyLink") || message
-            .contains(String(localized: "Pump did not respond in time"))
-    }
 }
 
 final class BaseAPSManager: APSManager, Injectable {
@@ -82,6 +83,7 @@ final class BaseAPSManager: APSManager, Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var tddStorage: TDDStorage!
     @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var trioAlertManager: TrioAlertManager!
     @Persisted(key: "lastLoopStartDate") private var lastLoopStartDate: Date = .distantPast
     @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
         didSet {
@@ -234,9 +236,22 @@ final class BaseAPSManager: APSManager, Injectable {
         Task { [weak self] in
             guard let self else { return }
 
+            // Consume the user-initiated flag unconditionally — it was set
+            // for the loop the user just triggered. If the guard below blocks
+            // (suspended, too-soon, no pump), the next scheduled tick must
+            // not inherit it and bypass dwell suppression for an error the
+            // user didn't request.
+            let userInitiated = self.nextLoopUserInitiated
+            self.nextLoopUserInitiated = false
+
             // Check if we can start a new loop
             guard await self.canStartNewLoop() else { return }
 
+            // Affects whether transient errors surface immediately instead of
+            // dwell-suppressed (see `surfaceErrorIfNeeded`).
+            self.currentLoopUserInitiated = userInitiated
+            defer { self.currentLoopUserInitiated = false }
+
             // Setup loop and background task
             var (loopStatRecord, backgroundTask) = await self.setupLoop()
 
@@ -267,6 +282,15 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func canStartNewLoop() async -> Bool {
+        // Don't try to run a loop while pump setup / pod pairing is in
+        // progress — `verifyStatus` would throw `invalidPumpState("Pump not
+        // set")` and surface a modal banner on top of the pod activation
+        // sheet, closing the sheet (reported by tester during O5 pairing).
+        guard pumpManager != nil else {
+            debug(.apsManager, "No pump manager — skipping loop attempt")
+            return false
+        }
+
         // Check if too soon for next loop
         if lastLoopDate > lastLoopStartDate {
             guard lastLoopStartDate.addingTimeInterval(Config.loopInterval) < Date() else {
@@ -374,6 +398,8 @@ final class BaseAPSManager: APSManager, Injectable {
             debug(.apsManager, "Loop succeeded")
             lastLoopDate = Date()
             lastError.send(nil)
+            transientCategoryFirstSeen.removeAll()
+            transientCategoryCount.removeAll()
         }
 
         loopStats(loopStatRecord: loopStatRecord)
@@ -607,9 +633,14 @@ final class BaseAPSManager: APSManager, Injectable {
             }
         } catch {
             warning(.apsManager, "Bolus failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus failed"),
+                body: String(localized: "Check pump history before repeating.")
+                    + "\n\n\(error.localizedDescription)"
+            )
             if !isSMB {
-                // Use MainActor to handle broadcaster notification
                 let broadcaster = self.broadcaster
                 Task { @MainActor in
                     broadcaster?.notify(BolusFailureObserver.self, on: .main) {
@@ -633,7 +664,12 @@ final class BaseAPSManager: APSManager, Injectable {
             callback?(true, String(localized: "Bolus cancelled successfully.", comment: "Success message for canceling a bolus"))
         } catch {
             debug(.apsManager, "Bolus cancellation failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus cancellation failed"),
+                body: String(localized: "Try again.") + "\n\n\(error.localizedDescription)"
+            )
             callback?(
                 false,
                 String(
@@ -724,10 +760,9 @@ final class BaseAPSManager: APSManager, Injectable {
             throw APSError.apsError(message: "Pump not set")
         }
 
-        // Check if pump is suspended and abort if it is
         if pump.status.pumpStatus.suspended {
-            info(.apsManager, "Skipping enactDetermination because pump is suspended")
-            return // return without throwing an error
+            debug(.apsManager, "Skipping enactDetermination because pump is suspended")
+            return
         }
 
         // Unable to do temp basal during manual temp basal 😁
@@ -1219,9 +1254,108 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
+    private var transientCategoryFirstSeen: [String: Date] = [:]
+    private var transientCategoryCount: [String: Int] = [:]
+    private static let transientDwellThreshold: TimeInterval = 60
+    private static let transientCountThreshold = 2
+
+    /// Set by `markNextLoopUserInitiated()` (e.g. force-loop button), consumed
+    /// on the next entry into `loop()` so that errors during a user-initiated
+    /// loop surface immediately instead of being suppressed by dwell logic.
+    @SyncAccess private var nextLoopUserInitiated: Bool = false
+    private var currentLoopUserInitiated: Bool = false
+
+    func markNextLoopUserInitiated() {
+        nextLoopUserInitiated = true
+    }
+
     private func processError(_ error: Error) {
         warning(.apsManager, "\(error)")
         lastError.send(error)
+        surfaceErrorIfNeeded(error)
+    }
+
+    private func surfaceErrorIfNeeded(_ error: Error) {
+        let category = TrioAlertClassifier.categorize(error: error)
+        let key = String(describing: category)
+
+        if category.shouldFireImmediately || currentLoopUserInitiated {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+            return
+        }
+
+        let now = Date()
+        let firstSeen = transientCategoryFirstSeen[key] ?? now
+        let count = (transientCategoryCount[key] ?? 0) + 1
+        let dwellElapsed = now.timeIntervalSince(firstSeen)
+        let dwellMet = dwellElapsed >= Self.transientDwellThreshold
+        let countMet = count >= Self.transientCountThreshold
+
+        if dwellMet || countMet {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+        } else {
+            transientCategoryFirstSeen[key] = firstSeen
+            transientCategoryCount[key] = count
+            debug(
+                .apsManager,
+                "APSManager suppressed transient \(category) (count=\(count)/\(Self.transientCountThreshold), dwell=\(Int(dwellElapsed))s/\(Int(Self.transientDwellThreshold))s)"
+            )
+        }
+    }
+
+    private func issueAlertForCategory(_ category: TrioAlertCategory, title: String, body: String) {
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: String(localized: "OK")
+        )
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
+    }
+
+    private func issueAlertForError(_ error: Error, category: TrioAlertCategory) {
+        let (title, body) = describeForAlert(error)
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: "OK"
+        )
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: category.alertIdentifier),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
+    }
+
+    private func describeForAlert(_ error: Error) -> (title: String, body: String) {
+        if let apsError = error as? APSError {
+            switch apsError {
+            case let .pumpError(inner):
+                return (
+                    String(localized: "Pump Error"),
+                    String(localized: "Trio could not communicate with the pump. Check the pump and try again.")
+                        + "\n\n\(inner.localizedDescription)"
+                )
+            case let .invalidPumpState(message): return (String(localized: "Pump State Error"), message)
+            case let .glucoseError(message): return (String(localized: "Glucose Error"), message)
+            case let .apsError(message): return (String(localized: "Algorithm Error"), message)
+            case let .manualBasalTemp(message): return (String(localized: "Manual Temp Basal Active"), message)
+            }
+        }
+        return ("Trio", error.localizedDescription)
     }
 
     private func createBolusReporter() {

+ 11 - 45
Trio/Sources/APS/DeviceDataManager.swift

@@ -62,6 +62,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var bluetoothProvider: BluetoothStateManager!
+    @Injected() private var trioAlertManager: TrioAlertManager!
 
     @Persisted(key: "BaseDeviceDataManager.lastEventDate") var lastEventDate: Date? = nil
     @SyncAccess(lock: accessLock) @Persisted(key: "BaseDeviceDataManager.lastHeartBeatTime") var lastHeartBeatTime: Date =
@@ -83,10 +84,16 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
 
     var pumpManager: PumpManagerUI? {
         didSet {
+            if let oldValue = oldValue {
+                trioAlertManager?.unregister(managerIdentifier: oldValue.pluginIdentifier)
+            }
             if let pumpManager = pumpManager {
                 pumpManager.pumpManagerDelegate = self
                 pumpManager.delegateQueue = processQueue
 
+                trioAlertManager?.register(responder: pumpManager, for: pumpManager.pluginIdentifier)
+                trioAlertManager?.register(soundVendor: pumpManager, for: pumpManager.pluginIdentifier)
+
                 /// Since the pump manager has been successfully instantiated from its saved state,
                 /// copy its rawValue to rawPumpManager which will be saved to persistant storage.
                 rawPumpManager = pumpManager.rawValue
@@ -241,7 +248,6 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
         injectServices(resolver)
         setupPumpManager()
         UIDevice.current.isBatteryMonitoringEnabled = true
-        broadcaster.register(AlertObserver.self, observer: self)
     }
 
     func setupPumpManager() {
@@ -635,22 +641,13 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
 
 extension BaseDeviceDataManager: DeviceManagerDelegate {
     func issueAlert(_ alert: Alert) {
-        alertHistoryStorage.addAlert(
-            AlertEntry(
-                alertIdentifier: alert.identifier.alertIdentifier,
-                primitiveInterruptionLevel: alert.interruptionLevel.storedValue as? Decimal,
-                issuedDate: Date(),
-                managerIdentifier: alert.identifier.managerIdentifier,
-                triggerType: alert.trigger.storedType,
-                triggerInterval: alert.trigger.storedInterval as? Decimal,
-                contentTitle: alert.foregroundContent?.title,
-                contentBody: alert.foregroundContent?.body
-            )
-        )
+        debug(.deviceManager, "issueAlert \(alert.identifier.value)")
+        trioAlertManager.issueAlert(alert)
     }
 
     func retractAlert(identifier: Alert.Identifier) {
-        alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
+        debug(.deviceManager, "retractAlert \(identifier.value)")
+        trioAlertManager.retractAlert(identifier: identifier)
     }
 
     func doesIssuedAlertExist(identifier _: Alert.Identifier, completion _: @escaping (Result<Bool, Error>) -> Void) {
@@ -705,37 +702,6 @@ extension BaseDeviceDataManager: CGMManagerDelegate {
     func cgmManager(_: CGMManager, didUpdate _: CGMManagerStatus) {}
 }
 
-// MARK: - AlertPresenter
-
-extension BaseDeviceDataManager: AlertObserver {
-    func AlertDidUpdate(_ alerts: [AlertEntry]) {
-        alerts.forEach { alert in
-            if alert.acknowledgedDate == nil {
-                ackAlert(alert: alert)
-            }
-        }
-    }
-
-    private func ackAlert(alert: AlertEntry) {
-        let alertIssueDate = alert.issuedDate
-
-        processQueue.async {
-            self.pumpManager?.acknowledgeAlert(alertIdentifier: alert.alertIdentifier) { error in
-                if let error = error {
-                    self.alertHistoryStorage.acknowledgeAlert(alertIssueDate, error.localizedDescription)
-                    debug(.deviceManager, "acknowledge not succeeded with error \(error)")
-                } else {
-                    self.alertHistoryStorage.acknowledgeAlert(alertIssueDate, nil)
-                }
-            }
-
-            self.broadcaster.notify(pumpNotificationObserver.self, on: self.processQueue) {
-                $0.pumpNotification(alert: alert)
-            }
-        }
-    }
-}
-
 // extension BaseDeviceDataManager: AlertPresenter {
 //    func issueAlert(_: Alert) {}
 //    func retractAlert(identifier _: Alert.Identifier) {}

+ 3 - 3
Trio/Sources/APS/Storage/GlucoseStorage.swift

@@ -214,6 +214,7 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
         guard context.hasChanges else { return }
         try context.save()
+        updateSubject.send()
     }
 
     private func storeGlucoseBatch(_ glucose: [BloodGlucose]) throws {
@@ -232,7 +233,6 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
             }
         )
         try context.execute(batchInsert)
-        // Only send update for batch insert since regular save triggers CoreData notifications
         updateSubject.send()
     }
 
@@ -771,11 +771,11 @@ final class BaseGlucoseStorage: GlucoseStorage, Injectable {
 
                 let glucoseValue = glucose.glucose
 
-                if Decimal(glucoseValue) <= settingsManager.settings.lowGlucose {
+                if Decimal(glucoseValue) <= settingsManager.settings.low {
                     return .low
                 }
 
-                if Decimal(glucoseValue) >= settingsManager.settings.highGlucose {
+                if Decimal(glucoseValue) >= settingsManager.settings.high {
                     return .high
                 }
 

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

@@ -90,6 +90,9 @@ extension Notification.Name {
             _ = resolver.resolve(LiveActivityManager.self)!
         }
         _ = resolver.resolve(IOBService.self)!
+        _ = resolver.resolve(GlucoseAlertCoordinator.self)!
+        _ = resolver.resolve(NotLoopingMonitor.self)!
+        _ = DeviceAlertsStore.shared
     }
 
     init() {

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

@@ -17,6 +17,12 @@ final class ServiceAssembly: Assembly {
         container.register(CalendarManager.self) { r in BaseCalendarManager(resolver: r) }
         container.register(HKHealthStore.self) { _ in HKHealthStore() }
         container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
+        container.register(TrioAlertManager.self) { r in BaseTrioAlertManager(resolver: r) }
+            .inObjectScope(.container)
+        container.register(GlucoseAlertCoordinator.self) { r in GlucoseAlertCoordinator(resolver: r) }
+            .inObjectScope(.container)
+        container.register(NotLoopingMonitor.self) { r in NotLoopingMonitor(resolver: r) }
+            .inObjectScope(.container)
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(BolusCalculationManager.self) { r in BaseBolusCalculationManager(resolver: r) }

Разница между файлами не показана из-за своего большого размера
+ 446 - 3
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 0 - 2
Trio/Sources/Models/DecimalPickerSettings.swift

@@ -33,8 +33,6 @@ class PickerSettingsProvider: ObservableObject {
 }
 
 struct DecimalPickerSettings {
-    var lowGlucose = PickerSetting(value: 70, step: 5, min: 40, max: 100, type: PickerSetting.PickerSettingType.glucose)
-    var highGlucose = PickerSetting(value: 180, step: 5, min: 100, max: 400, type: PickerSetting.PickerSettingType.glucose)
     var carbsRequiredThreshold = PickerSetting(value: 10, step: 1, min: 0, max: 100, type: PickerSetting.PickerSettingType.gram)
     var individualAdjustmentFactor = PickerSetting(
         value: 0.5,

+ 34 - 0
Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift

@@ -0,0 +1,34 @@
+import Foundation
+
+/// Catalog of the bundled critical-alarm sound files (`Trio/Resources/Sounds/`).
+/// Ported from Loop's audio-critical-alerts branch. Single source of truth
+/// for the alarm sound picker.
+enum AlarmSoundCatalog {
+    /// (filename, displayName) tuples in display order.
+    private static let catalog: [(filename: String, displayName: String)] = [
+        ("urgent_low.caf", String(localized: "Urgent Low")),
+        ("critical.caf", String(localized: "Critical")),
+        ("alarm.caf", String(localized: "Alarm")),
+        ("bright_alarm.caf", String(localized: "Bright Alarm")),
+        ("honk.caf", String(localized: "Honk")),
+        ("trill.caf", String(localized: "Trill")),
+        ("chime.caf", String(localized: "Chime")),
+        ("clear_chimes.caf", String(localized: "Clear Chimes")),
+        ("high_chimes.caf", String(localized: "High Chimes")),
+        ("dings.caf", String(localized: "Dings")),
+        ("bloom.caf", String(localized: "Bloom")),
+        ("bloop.caf", String(localized: "Bloop")),
+        ("spring.caf", String(localized: "Spring")),
+        ("minimal.caf", String(localized: "Minimal")),
+        ("simple.caf", String(localized: "Simple")),
+        ("synth.caf", String(localized: "Synth")),
+        ("mood_synth.caf", String(localized: "Mood Synth")),
+        ("crying.caf", String(localized: "Crying"))
+    ]
+
+    static let allFilenames: [String] = catalog.map(\.filename)
+
+    static func displayName(for filename: String) -> String {
+        catalog.first { $0.filename == filename }?.displayName ?? filename
+    }
+}

+ 33 - 0
Trio/Sources/Models/GlucoseAlerts/DayNightOptions.swift

@@ -0,0 +1,33 @@
+import Foundation
+
+protocol DayNightDisplayable {
+    var displayName: String { get }
+}
+
+extension DayNightDisplayable where Self: RawRepresentable, Self.RawValue == String {
+    var displayName: String {
+        rawValue == "always"
+            ? String(localized: "Day & Night")
+            : rawValue.localizedCapitalized
+    }
+}
+
+enum ActiveOption: String, CaseIterable, Codable, Identifiable, DayNightDisplayable {
+    case always
+    case day
+    case night
+
+    var id: String { rawValue }
+}
+
+struct TimeOfDay: Codable, Equatable, Hashable {
+    var hour: Int
+    var minute: Int
+
+    init(hour: Int, minute: Int) {
+        self.hour = max(0, min(23, hour))
+        self.minute = max(0, min(59, minute))
+    }
+
+    var minutesSinceMidnight: Int { hour * 60 + minute }
+}

+ 62 - 0
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift

@@ -0,0 +1,62 @@
+import Foundation
+import LoopKit
+
+/// Coarse-grained tier the user configures for device alarms. Wraps
+/// `Alert.InterruptionLevel` plus its visible behavior:
+/// - `.critical` overrides Silence & Focus Mode (uses the critical-audio
+///   fallback for builds without the Critical Alerts entitlement)
+/// - `.timeSensitive` pierces normal banner suppression but obeys DND/silent
+/// - `.normal` fires only when the device isn't silenced — informational
+enum DeviceAlertSeverity: String, Codable, CaseIterable, Identifiable {
+    case critical
+    case timeSensitive
+    case normal
+
+    var id: String { rawValue }
+
+    init?(level: Alert.InterruptionLevel) {
+        switch level {
+        case .critical: self = .critical
+        case .timeSensitive: self = .timeSensitive
+        case .active: self = .normal
+        }
+    }
+
+    var displayName: String {
+        switch self {
+        case .critical: return String(localized: "Critical")
+        case .timeSensitive: return String(localized: "Time-Sensitive")
+        case .normal: return String(localized: "Normal")
+        }
+    }
+
+    var blurb: String {
+        switch self {
+        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.")
+        case .normal:
+            return String(localized: "Default notification banner. Suppressed by Silence & Focus Mode.")
+        }
+    }
+
+    var defaultSoundFilename: String {
+        switch self {
+        case .critical: return "alarm.caf"
+        case .timeSensitive: return "chime.caf"
+        case .normal: return "bloop.caf"
+        }
+    }
+
+    /// Default for the per-tier override toggle when seeded. Tier names are
+    /// labels now — the actual `Alert.InterruptionLevel` is derived from the
+    /// override flag at fire time (true → `.critical`, false → `.timeSensitive`).
+    var defaultOverridesSilenceAndDND: Bool {
+        switch self {
+        case .critical: return true
+        case .normal,
+             .timeSensitive: return false
+        }
+    }
+}

+ 63 - 0
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverityConfig.swift

@@ -0,0 +1,63 @@
+import Foundation
+
+/// One user-configured behavior variant for a device-alarm severity tier.
+/// Multiple configs per severity are allowed — each with its own
+/// `activeOption` (Day & Night / Day only / Night only) — so the user can
+/// e.g. have a Critical config that overrides Silence during the day and
+/// a second Critical config that goes silent at night.
+///
+/// Lookup at fire time picks the variant whose `activeOption` matches the
+/// current day/night window, falling back to the `.always` variant.
+struct DeviceAlertSeverityConfig: Codable, Equatable, Identifiable {
+    var id: UUID
+    var severity: DeviceAlertSeverity
+    var isEnabled: Bool
+    var soundFilename: String
+    var playsSound: Bool
+    /// When true, alarms in this tier bypass Focus Mode / silent switch
+    /// (maps to `Alert.InterruptionLevel.critical` and engages the in-process
+    /// `CriticalAlertAudioPlayer` fallback if `playsSound` is true).
+    /// When false, the alarm uses `.timeSensitive`.
+    var overridesSilenceAndDND: Bool
+    var activeOption: ActiveOption
+
+    init(
+        id: UUID = UUID(),
+        severity: DeviceAlertSeverity,
+        activeOption: ActiveOption = .always
+    ) {
+        self.id = id
+        self.severity = severity
+        isEnabled = true
+        soundFilename = severity.defaultSoundFilename
+        playsSound = true
+        overridesSilenceAndDND = severity.defaultOverridesSilenceAndDND
+        self.activeOption = activeOption
+    }
+
+    // MARK: - Codable
+
+    private enum CodingKeys: String, CodingKey {
+        case id
+        case severity
+        case isEnabled
+        case soundFilename
+        case playsSound
+        case overridesSilenceAndDND
+        case activeOption
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
+        severity = try container.decode(DeviceAlertSeverity.self, forKey: .severity)
+        isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
+        soundFilename = try container.decodeIfPresent(String.self, forKey: .soundFilename) ?? severity.defaultSoundFilename
+        playsSound = try container.decodeIfPresent(Bool.self, forKey: .playsSound) ?? true
+        overridesSilenceAndDND = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .overridesSilenceAndDND
+        ) ?? severity.defaultOverridesSilenceAndDND
+        activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always
+    }
+}

+ 77 - 0
Trio/Sources/Models/GlucoseAlerts/GlucoseAlert.swift

@@ -0,0 +1,77 @@
+import Foundation
+
+/// A single configurable glucose alarm. Multiple entries of the same type are
+/// allowed — e.g. a Low alarm `active: .day` at 80 mg/dL plus a second Low
+/// `active: .night` at 70 mg/dL.
+struct GlucoseAlert: Identifiable, Codable, Equatable {
+    var id: UUID
+    var type: GlucoseAlertType
+    var name: String
+    var isEnabled: Bool
+    var thresholdMgDL: Decimal
+    var soundFilename: String
+    /// When false, the alarm fires the banner / notification but no sound.
+    /// iOS still drives haptics from the interruption level.
+    var playsSound: Bool
+    /// When true, this alarm bypasses Focus Mode / silent switch
+    /// modes. Maps to `Alert.InterruptionLevel.critical` and triggers the
+    /// in-process `CriticalAlertAudioPlayer` fallback for builds without the
+    /// Critical Alerts entitlement.
+    var overridesSilenceAndDND: Bool
+    var activeOption: ActiveOption
+    /// Per-alarm snooze. Distinct from the global mute on `AlertMuter`.
+    var snoozedUntil: Date?
+
+    init(type: GlucoseAlertType) {
+        id = UUID()
+        self.type = type
+        name = type.displayName
+        isEnabled = true
+        thresholdMgDL = type.defaultThresholdMgDL
+        soundFilename = type.defaultSoundFilename
+        playsSound = true
+        overridesSilenceAndDND = type.defaultOverridesSilenceAndDND
+        activeOption = .always
+        snoozedUntil = nil
+    }
+
+    /// Whether the coordinator should fire this alarm when a reading breaches.
+    /// Urgent-low is the safety floor — the editor hides the Enabled toggle so
+    /// the user can't accidentally turn it off, and stored `isEnabled = false`
+    /// from a prior install is ignored here.
+    var shouldEvaluate: Bool {
+        type == .urgentLow || isEnabled
+    }
+
+    // MARK: - Codable
+
+    private enum CodingKeys: String, CodingKey {
+        case id
+        case type
+        case name
+        case isEnabled
+        case thresholdMgDL
+        case soundFilename
+        case playsSound
+        case overridesSilenceAndDND
+        case activeOption
+        case snoozedUntil
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        id = try container.decode(UUID.self, forKey: .id)
+        type = try container.decode(GlucoseAlertType.self, forKey: .type)
+        name = try container.decode(String.self, forKey: .name)
+        isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
+        thresholdMgDL = try container.decode(Decimal.self, forKey: .thresholdMgDL)
+        soundFilename = try container.decodeIfPresent(String.self, forKey: .soundFilename) ?? type.defaultSoundFilename
+        playsSound = try container.decodeIfPresent(Bool.self, forKey: .playsSound) ?? true
+        overridesSilenceAndDND = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .overridesSilenceAndDND
+        ) ?? type.defaultOverridesSilenceAndDND
+        activeOption = try container.decodeIfPresent(ActiveOption.self, forKey: .activeOption) ?? .always
+        snoozedUntil = try container.decodeIfPresent(Date.self, forKey: .snoozedUntil)
+    }
+}

+ 63 - 0
Trio/Sources/Models/GlucoseAlerts/GlucoseAlertConfiguration.swift

@@ -0,0 +1,63 @@
+import Foundation
+
+struct GlucoseAlertConfiguration: Codable, Equatable {
+    var dayStart: TimeOfDay
+    var nightStart: TimeOfDay
+    /// Force Trio alarms on even when the CGM advertises its own.
+    var forceTrioAlertsWhenCGMProvidesOwn: Bool
+
+    init(
+        dayStart: TimeOfDay = TimeOfDay(hour: 6, minute: 0),
+        nightStart: TimeOfDay = TimeOfDay(hour: 22, minute: 0),
+        forceTrioAlertsWhenCGMProvidesOwn: Bool = false
+    ) {
+        self.dayStart = dayStart
+        self.nightStart = nightStart
+        self.forceTrioAlertsWhenCGMProvidesOwn = forceTrioAlertsWhenCGMProvidesOwn
+    }
+
+    private enum CodingKeys: String, CodingKey {
+        case dayStart
+        case nightStart
+        case forceTrioAlertsWhenCGMProvidesOwn
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        dayStart = try container.decode(TimeOfDay.self, forKey: .dayStart)
+        nightStart = try container.decode(TimeOfDay.self, forKey: .nightStart)
+        forceTrioAlertsWhenCGMProvidesOwn = try container.decodeIfPresent(
+            Bool.self,
+            forKey: .forceTrioAlertsWhenCGMProvidesOwn
+        ) ?? false
+    }
+
+    /// Resolve whether `date` falls into the user's "night" window. Mirrors
+    /// LoopFollow's logic: handles both same-day (06→22) and wrap-around
+    /// (22→06) ranges. When `nightStart >= dayStart`, night is "later than
+    /// nightStart OR earlier than dayStart"; otherwise night is the slice
+    /// between nightStart and dayStart.
+    func isNight(at date: Date, calendar: Calendar = .current) -> Bool {
+        let startOfDay = calendar.startOfDay(for: date)
+        guard
+            let dayStartDate = calendar.date(
+                bySettingHour: dayStart.hour,
+                minute: dayStart.minute,
+                second: 0,
+                of: startOfDay
+            ),
+            let nightStartDate = calendar.date(
+                bySettingHour: nightStart.hour,
+                minute: nightStart.minute,
+                second: 0,
+                of: startOfDay
+            )
+        else { return false }
+
+        if nightStartDate >= dayStartDate {
+            return date >= nightStartDate || date < dayStartDate
+        } else {
+            return date >= nightStartDate && date < dayStartDate
+        }
+    }
+}

+ 103 - 0
Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift

@@ -0,0 +1,103 @@
+import Foundation
+import LoopKit
+
+/// Glucose-driven alarm types Trio supports today. Order is priority order
+/// (`urgentLow` highest), matching how `AlarmType.priority` works in
+/// LoopFollow — when multiple alarms would fire on the same reading, the
+/// higher-priority one wins and lower types of the same group are skipped.
+enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable {
+    case urgentLow
+    case low
+    case forecastedLow
+    case high
+    /// Driven by `Determination.carbsReq`, not by a glucose reading. Stored
+    /// alongside the other glucose alarms so the user has one place to
+    /// configure schedule/sound/snooze for everything fired by Trio.
+    case carbsRequired
+
+    var id: String { rawValue }
+
+    var priority: Int { Self.allCases.firstIndex(of: self) ?? 0 }
+
+    /// `true` when the alarm fires off a CGM glucose reading. `false` for
+    /// `forecastedLow` (driven by the determination forecast) and
+    /// `carbsRequired` (driven by the determination's `carbsReq` field).
+    var isReadingDriven: Bool {
+        switch self {
+        case .high,
+             .low,
+             .urgentLow:
+            return true
+        case .carbsRequired,
+             .forecastedLow:
+            return false
+        }
+    }
+
+    /// Parses a glucose-alarm slug emitted by `GlucoseAlertCoordinator`
+    /// (`glucose.<type>.<uuid>`). Returns nil for non-glucose alert
+    /// identifiers — used by `BaseTrioAlertManager.requestSnooze` to decide
+    /// between per-type and global mute routing.
+    init?(slug: String) {
+        let parts = slug.split(separator: ".")
+        guard parts.count >= 2, parts[0] == "glucose" else { return nil }
+        guard let parsed = GlucoseAlertType(rawValue: String(parts[1])) else { return nil }
+        self = parsed
+    }
+
+    var displayName: String {
+        switch self {
+        case .urgentLow: return String(localized: "Urgent Low Glucose")
+        case .low: return String(localized: "Low Glucose")
+        case .forecastedLow: return String(localized: "Low Glucose Soon")
+        case .high: return String(localized: "High Glucose")
+        case .carbsRequired: return String(localized: "Carbs Required")
+        }
+    }
+
+    var blurb: String {
+        switch self {
+        case .urgentLow: return String(localized: "Fires when glucose drops to or below an urgent low threshold.")
+        case .low: return String(localized: "Fires when glucose drops to or below a low threshold.")
+        case .forecastedLow: return String(localized: "Fires when glucose is forecasted to be low within the next 20 minutes.")
+        case .high: return String(localized: "Fires when glucose rises to or above a high threshold.")
+        case .carbsRequired: return String(localized: "Fires when oref recommends eating carbs to avoid a low.")
+        }
+    }
+
+    /// Default threshold when adding a new alarm. Mg/dL for glucose types,
+    /// grams for `carbsRequired`.
+    var defaultThresholdMgDL: Decimal {
+        switch self {
+        case .urgentLow: return 54
+        case .low: return 72
+        case .forecastedLow: return 72
+        case .high: return 270
+        case .carbsRequired: return 10
+        }
+    }
+
+    /// Default bundled sound filename. See `Trio/Resources/Sounds/`.
+    var defaultSoundFilename: String {
+        switch self {
+        case .urgentLow: return "urgent_low.caf"
+        case .low: return "trill.caf"
+        case .forecastedLow: return "bloom.caf"
+        case .high: return "chime.caf"
+        case .carbsRequired: return "bloop.caf"
+        }
+    }
+
+    /// Default for `GlucoseAlert.overridesSilenceAndDND` when adding a new
+    /// alarm. Urgent-low defaults to override-on to match Loop's stance;
+    /// others default off (time-sensitive, doesn't pierce DND / Focus).
+    var defaultOverridesSilenceAndDND: Bool {
+        switch self {
+        case .urgentLow: return true
+        case .carbsRequired,
+             .forecastedLow,
+             .high,
+             .low: return false
+        }
+    }
+}

+ 0 - 27
Trio/Sources/Models/GlucoseNotificationsOption.swift

@@ -1,27 +0,0 @@
-//
-//  GlucoseNotificationOption.swift
-//  FreeAPS
-//
-//  Created by Kimberlie Skandis on 1/18/25.
-//
-import Foundation
-import SwiftUI
-
-public enum GlucoseNotificationsOption: String, JSON, CaseIterable, Identifiable, Codable, Hashable {
-    case disabled
-    case alwaysEveryCGM
-    case onlyAlarmLimits
-
-    public var id: String { rawValue }
-
-    var displayName: String {
-        switch self {
-        case .disabled:
-            return String(localized: "Disabled", comment: "Option to disable glucose notifications")
-        case .alwaysEveryCGM:
-            return String(localized: "Always", comment: "Option to always notify on every CGM reading")
-        case .onlyAlarmLimits:
-            return String(localized: "Only Alarm Limits", comment: "Option to notify only when glucose reaches alarm limits")
-        }
-    }
-}

+ 10 - 0
Trio/Sources/Models/GlucoseSourceKey.swift

@@ -0,0 +1,10 @@
+import Foundation
+
+/// Keys used by `GlucoseSource.sourceInfo()` implementations to advertise
+/// metadata about the active CGM (description, transmitter battery,
+/// Nightscout ping). Consumed by status views + diagnostics.
+enum GlucoseSourceKey: String {
+    case transmitterBattery
+    case nightscoutPing
+    case description
+}

+ 8 - 8
Trio/Sources/Models/NotificationIdentifiers.swift

@@ -6,7 +6,7 @@ enum NotificationCategoryIdentifier: String {
 }
 
 enum NotificationResponseAction: String, CaseIterable {
-    case snooze20 = "Trio.snooze20"
+    case snooze15 = "Trio.snooze15"
     case snooze1hr = "Trio.snooze1hr"
     case snooze3hr = "Trio.snooze3hr"
     case snooze6hr = "Trio.snooze6hr"
@@ -17,8 +17,8 @@ enum NotificationResponseAction: String, CaseIterable {
 
     var minutes: Int {
         switch self {
-        case .snooze20:
-            return 20
+        case .snooze15:
+            return 15
         case .snooze1hr:
             return 60
         case .snooze3hr:
@@ -30,14 +30,14 @@ enum NotificationResponseAction: String, CaseIterable {
 
     var localizedTitle: String {
         switch self {
-        case .snooze20:
-            return String(localized: "20 min", comment: "Snooze glucose alerts for 20 minutes")
+        case .snooze15:
+            return String(localized: "Snooze 15 min", comment: "Snooze glucose alerts for 15 minutes")
         case .snooze1hr:
-            return String(localized: "1 hour", comment: "Snooze glucose alerts for 1 hour")
+            return String(localized: "Snooze 1 hr", comment: "Snooze glucose alerts for 1 hour")
         case .snooze3hr:
-            return String(localized: "3 hours", comment: "Snooze glucose alerts for 3 hours")
+            return String(localized: "Snooze 3 hrs", comment: "Snooze glucose alerts for 3 hours")
         case .snooze6hr:
-            return String(localized: "6 hours", comment: "Snooze glucose alerts for 6 hours")
+            return String(localized: "Snooze 6 hrs", comment: "Snooze glucose alerts for 6 hours")
         }
     }
 }

+ 0 - 46
Trio/Sources/Models/TrioSettings.swift

@@ -30,14 +30,6 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var displayCalendarIOBandCOB: Bool = false
     var displayCalendarEmojis: Bool = false
     var glucoseBadge: Bool = false
-    var notificationsPump: Bool = true
-    var notificationsCgm: Bool = true
-    var notificationsCarb: Bool = true
-    var notificationsAlgorithm: Bool = true
-    var glucoseNotificationsOption: GlucoseNotificationsOption = .onlyAlarmLimits
-    var addSourceInfoToGlucoseNotifications: Bool = false
-    var lowGlucose: Decimal = 72
-    var highGlucose: Decimal = 270
     var carbsRequiredThreshold: Decimal = 10
     var showCarbsRequiredBadge: Bool = true
     var useFPUconversion: Bool = false
@@ -214,44 +206,6 @@ extension TrioSettings: Decodable {
             settings.delay = delay
         }
 
-        if let notificationsPump = try? container.decode(Bool.self, forKey: .notificationsPump) {
-            settings.notificationsPump = notificationsPump
-        }
-
-        if let notificationsCgm = try? container.decode(Bool.self, forKey: .notificationsCgm) {
-            settings.notificationsCgm = notificationsCgm
-        }
-
-        if let notificationsCarb = try? container.decode(Bool.self, forKey: .notificationsCarb) {
-            settings.notificationsCarb = notificationsCarb
-        }
-
-        if let notificationsAlgorithm = try? container.decode(Bool.self, forKey: .notificationsAlgorithm) {
-            settings.notificationsAlgorithm = notificationsAlgorithm
-        }
-
-        if let glucoseNotificationsOption = try? container.decode(
-            GlucoseNotificationsOption.self,
-            forKey: .glucoseNotificationsOption
-        ) {
-            settings.glucoseNotificationsOption = glucoseNotificationsOption
-        }
-
-        if let addSourceInfoToGlucoseNotifications = try? container.decode(
-            Bool.self,
-            forKey: .addSourceInfoToGlucoseNotifications
-        ) {
-            settings.addSourceInfoToGlucoseNotifications = addSourceInfoToGlucoseNotifications
-        }
-
-        if let lowGlucose = try? container.decode(Decimal.self, forKey: .lowGlucose) {
-            settings.lowGlucose = lowGlucose
-        }
-
-        if let highGlucose = try? container.decode(Decimal.self, forKey: .highGlucose) {
-            settings.highGlucose = highGlucose
-        }
-
         if let carbsRequiredThreshold = try? container.decode(Decimal.self, forKey: .carbsRequiredThreshold) {
             settings.carbsRequiredThreshold = carbsRequiredThreshold
         }

+ 7 - 0
Trio/Sources/Modules/AlarmWindows/AlarmWindowsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum AlarmWindows {
+    enum Config {}
+}
+
+protocol AlarmWindowsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/AlarmWindows/AlarmWindowsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension AlarmWindows {
+    final class Provider: BaseProvider, AlarmWindowsProvider {}
+}

+ 18 - 0
Trio/Sources/Modules/AlarmWindows/AlarmWindowsStateModel.swift

@@ -0,0 +1,18 @@
+import Combine
+import SwiftUI
+
+extension AlarmWindows {
+    final class StateModel: BaseStateModel<Provider> {
+        @Published var units: GlucoseUnits = .mgdL
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+        }
+    }
+}
+
+extension AlarmWindows.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 135 - 0
Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift

@@ -0,0 +1,135 @@
+import SwiftUI
+import Swinject
+
+extension AlarmWindows {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = GlucoseAlertsStore.shared
+
+        @State private var shouldDisplayHint: Bool = false
+        @State private var hintDetent = PresentationDetent.large
+        @State private var selectedVerboseHint: AnyView?
+        @State private var hintLabel: String?
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        private var dayStart: Binding<Date> {
+            Binding(
+                get: { Self.dateFromTimeOfDay(store.configuration.dayStart) },
+                set: { store.configuration.dayStart = Self.timeOfDay(from: $0) }
+            )
+        }
+
+        private var nightStart: Binding<Date> {
+            Binding(
+                get: { Self.dateFromTimeOfDay(store.configuration.nightStart) },
+                set: { store.configuration.nightStart = Self.timeOfDay(from: $0) }
+            )
+        }
+
+        var body: some View {
+            List {
+                Section(header: Text("Window Boundaries")) {
+                    VStack {
+                        DatePicker(
+                            selection: dayStart,
+                            displayedComponents: .hourAndMinute
+                        ) {
+                            HStack {
+                                Image(systemName: "sun.max.fill").foregroundStyle(.orange)
+                                Text("Day Starts")
+                            }
+                        }
+                        .padding(.top)
+
+                        DatePicker(
+                            selection: nightStart,
+                            displayedComponents: .hourAndMinute
+                        ) {
+                            HStack {
+                                Image(systemName: "moon.stars.fill").foregroundStyle(.indigo)
+                                Text("Night Starts")
+                            }
+                        }
+
+                        HStack(alignment: .center) {
+                            Text("Decides when each alarm's Day or Night setting applies.")
+                                .lineLimit(nil)
+                                .font(.footnote)
+                                .foregroundColor(.secondary)
+
+                            Spacer()
+                            Button(
+                                action: {
+                                    hintLabel = String(localized: "Day and Night Window")
+                                    let dayDefault = Self.formattedTimeOfDay(hour: 6, minute: 0)
+                                    let nightDefault = Self.formattedTimeOfDay(hour: 22, minute: 0)
+                                    selectedVerboseHint = AnyView(
+                                        VStack(alignment: .leading, spacing: 10) {
+                                            Text(
+                                                String(
+                                                    format: String(localized: "Default: Day starts %1$@, Night starts %2$@."),
+                                                    dayDefault,
+                                                    nightDefault
+                                                )
+                                            ).bold()
+                                            Text(
+                                                "These two times define the Day and Night windows. Each alarm's Active setting picks one — Day & Night, Day only, or Night only — and only fires when that window is current."
+                                            )
+                                            Text(
+                                                String(
+                                                    format: String(
+                                                        localized: "The Night window runs from 'Night Starts' back around to 'Day Starts' — so by default, Night covers %1$@ through %2$@ the next morning."
+                                                    ),
+                                                    nightDefault,
+                                                    dayDefault
+                                                )
+                                            )
+                                            Text(
+                                                "These windows are shared between Glucose Alarms and Pump Alarms."
+                                            )
+                                        }
+                                    )
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: { Image(systemName: "questionmark.circle") }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Day & Night Windows")
+            .navigationBarTitleDisplayMode(.inline)
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help")
+                )
+            }
+            .onAppear(perform: configureView)
+        }
+
+        private static func dateFromTimeOfDay(_ time: TimeOfDay) -> Date {
+            Calendar.current.date(bySettingHour: time.hour, minute: time.minute, second: 0, of: Date()) ?? Date()
+        }
+
+        private static func timeOfDay(from date: Date) -> TimeOfDay {
+            let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
+            return TimeOfDay(hour: comps.hour ?? 0, minute: comps.minute ?? 0)
+        }
+
+        /// Locale-aware HH:mm / h:mm a. Used so 12-hour locales see "10:00 PM"
+        /// instead of "22:00" in hint copy.
+        private static func formattedTimeOfDay(hour: Int, minute: Int) -> String {
+            let date = Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: Date()) ?? Date()
+            return date.formatted(.dateTime.hour().minute())
+        }
+    }
+}

+ 7 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum DeviceAlarms {
+    enum Config {}
+}
+
+protocol DeviceAlarmsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension DeviceAlarms {
+    final class Provider: BaseProvider, DeviceAlarmsProvider {}
+}

+ 8 - 0
Trio/Sources/Modules/DeviceAlarms/DeviceAlarmsStateModel.swift

@@ -0,0 +1,8 @@
+import Combine
+import SwiftUI
+
+extension DeviceAlarms {
+    final class StateModel: BaseStateModel<Provider> {
+        override func subscribe() {}
+    }
+}

+ 65 - 0
Trio/Sources/Modules/DeviceAlarms/View/AddDeviceAlarmSheet.swift

@@ -0,0 +1,65 @@
+import SwiftUI
+
+struct AddDeviceAlarmSheet: View {
+    let onPick: (DeviceAlertSeverity) -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section {
+                    ForEach(DeviceAlertSeverity.allCases) { severity in
+                        Button {
+                            onPick(severity)
+                            dismiss()
+                        } label: {
+                            HStack(spacing: 12) {
+                                Image(systemName: icon(for: severity))
+                                    .foregroundStyle(tint(for: severity))
+                                    .frame(width: 28)
+                                VStack(alignment: .leading, spacing: 2) {
+                                    Text(severity.displayName)
+                                        .foregroundColor(.primary)
+                                    Text(severity.blurb)
+                                        .font(.footnote)
+                                        .foregroundColor(.secondary)
+                                }
+                                Spacer()
+                                Image(systemName: "chevron.right")
+                                    .font(.footnote)
+                                    .foregroundColor(.secondary)
+                            }
+                        }
+                    }
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Add Variant")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) { dismiss() }
+                }
+            }
+        }
+    }
+
+    private func icon(for severity: DeviceAlertSeverity) -> String {
+        switch severity {
+        case .critical: return "exclamationmark.triangle.fill"
+        case .timeSensitive: return "bell.badge.fill"
+        case .normal: return "bell.fill"
+        }
+    }
+
+    private func tint(for severity: DeviceAlertSeverity) -> Color {
+        switch severity {
+        case .critical: return .red
+        case .timeSensitive: return .orange
+        case .normal: return .accentColor
+        }
+    }
+}

+ 129 - 0
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift

@@ -0,0 +1,129 @@
+import LoopKit
+import SwiftUI
+
+struct DeviceAlarmEditorView: View {
+    @ObservedObject var store: DeviceAlertsStore
+    let configID: UUID
+    let isNew: Bool
+    var onDone: () -> Void
+    var onCancel: () -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @State private var working: DeviceAlertSeverityConfig
+
+    init(
+        store: DeviceAlertsStore,
+        initial: DeviceAlertSeverityConfig,
+        isNew: Bool,
+        onDone: @escaping () -> Void,
+        onCancel: @escaping () -> Void = {}
+    ) {
+        self.store = store
+        configID = initial.id
+        self.isNew = isNew
+        self.onDone = onDone
+        self.onCancel = onCancel
+        _working = State(initialValue: initial)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                Section(
+                    header: Text("Behavior"),
+                    footer: Text(working.severity.blurb)
+                ) {
+                    HStack {
+                        Text(working.severity.displayName).font(.headline)
+                        Spacer()
+                        Text(activeLabel)
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
+                    // Critical-tier configs are always armed — the user can
+                    // mute the sound via the Audio section but not turn the
+                    // alarm itself off. Other tiers expose the toggle.
+                    if working.severity != .critical {
+                        Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+                    }
+                    Toggle(
+                        String(localized: "Override Silence & Focus Mode"),
+                        isOn: $working.overridesSilenceAndDND
+                    )
+                }.listRowBackground(Color.chart)
+
+                AlarmActiveSection(activeOption: $working.activeOption)
+                AlarmAudioSection(
+                    playsSound: $working.playsSound,
+                    soundFilename: $working.soundFilename
+                )
+
+                Section(header: Text("Applies To")) {
+                    ForEach(conceptsForTier(working.severity), id: \.self) { concept in
+                        Text(concept.displayTitle)
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
+                }.listRowBackground(Color.chart)
+
+                if !isNew, store.canDelete(working) {
+                    Section {
+                        Button(role: .destructive) {
+                            store.remove(working)
+                            dismiss()
+                        } label: {
+                            Text("Delete Variant")
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle(working.severity.displayName)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button(isNew ? String(localized: "Add") : String(localized: "Done")) {
+                        if isNew {
+                            store.add(working)
+                        } else {
+                            store.update(working)
+                        }
+                        onDone()
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) {
+                        onCancel()
+                        dismiss()
+                    }
+                }
+            }
+        }
+    }
+
+    private var activeLabel: String {
+        switch working.activeOption {
+        case .always: return String(localized: "Day & Night")
+        case .day: return String(localized: "Day only")
+        case .night: return String(localized: "Night only")
+        }
+    }
+
+    /// Distinct alarm concepts whose catalog entries fall into this tier.
+    /// Sorted by display title so the list is stable across plugin changes.
+    private func conceptsForTier(_ tier: DeviceAlertSeverity) -> [LoopKit.Alert.CatalogConcept] {
+        var seen: Set<LoopKit.Alert.CatalogConcept> = []
+        var ordered: [LoopKit.Alert.CatalogConcept] = []
+        for entry in AlertCatalogRegistry.entries
+            where DeviceAlertSeverity(level: entry.interruptionLevel) == tier
+        {
+            if seen.insert(entry.concept).inserted {
+                ordered.append(entry.concept)
+            }
+        }
+        return ordered.sorted { $0.displayTitle < $1.displayTitle }
+    }
+}

+ 192 - 0
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift

@@ -0,0 +1,192 @@
+import SwiftUI
+import Swinject
+
+private enum DeviceAlarmSheet: Identifiable {
+    case picker
+    case editor(DeviceAlertSeverityConfig, isNew: Bool)
+
+    var id: String {
+        switch self {
+        case .picker: return "picker"
+        case let .editor(config, _): return config.id.uuidString
+        }
+    }
+}
+
+extension DeviceAlarms {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = DeviceAlertsStore.shared
+
+        @State private var sheet: DeviceAlarmSheet?
+        @State private var pendingNewSeverity: DeviceAlertSeverity?
+
+        @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)
+                    ) {
+                        ForEach(store.configs(in: severity)) { config in
+                            row(for: config)
+                                .opacity(config.isEnabled ? 1 : 0.5)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+
+                Section {
+                    Text("Day & Night Windows")
+                        .navigationLink(to: .alarmWindows, from: self)
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Pump Alarms")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .primaryAction) {
+                    Button { sheet = .picker } label: { Image(systemName: "plus") }
+                }
+            }
+            .sheet(item: $sheet, onDismiss: handleSheetDismiss) { which in
+                switch which {
+                case .picker:
+                    AddDeviceAlarmSheet { severity in
+                        pendingNewSeverity = severity
+                        sheet = nil
+                    }
+                case let .editor(config, isNew):
+                    DeviceAlarmEditorView(
+                        store: store,
+                        initial: config,
+                        isNew: isNew,
+                        onDone: { sheet = nil },
+                        onCancel: { sheet = nil }
+                    )
+                }
+            }
+            .onAppear(perform: configureView)
+        }
+
+        private func handleSheetDismiss() {
+            guard let severity = pendingNewSeverity else { return }
+            pendingNewSeverity = nil
+            DispatchQueue.main.async {
+                let new = DeviceAlertSeverityConfig(
+                    severity: severity,
+                    activeOption: nextAvailableOption(for: severity)
+                )
+                sheet = .editor(new, isNew: true)
+            }
+        }
+
+        // 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 {
+            Button {
+                sheet = .editor(config, isNew: false)
+            } label: {
+                HStack(spacing: 12) {
+                    AlarmWindowIcon(option: config.activeOption)
+                        .font(.title3)
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(windowLabel(for: config.activeOption))
+                            .foregroundColor(.primary)
+                        soundSummary(for: config)
+                    }
+                    Spacer()
+                    Image(systemName: "chevron.right")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                if store.canDelete(config) {
+                    Button(role: .destructive) {
+                        store.remove(config)
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
+                }
+            }
+        }
+
+        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)
+            }
+            .font(.footnote)
+            .foregroundColor(.secondary)
+        }
+
+        // MARK: - Helpers
+
+        private func windowLabel(for option: ActiveOption) -> String {
+            switch option {
+            case .always: return String(localized: "Day & Night")
+            case .day: return String(localized: "Day only")
+            case .night: return String(localized: "Night only")
+            }
+        }
+
+        private func severityIcon(for severity: DeviceAlertSeverity) -> String {
+            switch severity {
+            case .critical: return "exclamationmark.triangle.fill"
+            case .timeSensitive: return "bell.badge.fill"
+            case .normal: return "bell.fill"
+            }
+        }
+
+        private func severityTint(for severity: DeviceAlertSeverity) -> Color {
+            switch severity {
+            case .critical: return .red
+            case .timeSensitive: return .orange
+            case .normal: return .accentColor
+            }
+        }
+
+        /// Suggest an `ActiveOption` not yet used in this severity tier so the
+        /// editor opens on a meaningful new variant instead of duplicating
+        /// the existing `.always` row.
+        private func nextAvailableOption(for severity: DeviceAlertSeverity) -> ActiveOption {
+            let used = Set(store.configs(in: severity).map(\.activeOption))
+            for candidate in [ActiveOption.day, .night, .always] where !used.contains(candidate) {
+                return candidate
+            }
+            return .day
+        }
+    }
+}

+ 7 - 0
Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsDataFlow.swift

@@ -0,0 +1,7 @@
+import Combine
+
+enum GlucoseAlerts {
+    enum Config {}
+}
+
+protocol GlucoseAlertsProvider: Provider {}

+ 5 - 0
Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsProvider.swift

@@ -0,0 +1,5 @@
+import Foundation
+
+extension GlucoseAlerts {
+    final class Provider: BaseProvider, GlucoseAlertsProvider {}
+}

+ 36 - 0
Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsStateModel.swift

@@ -0,0 +1,36 @@
+import Combine
+import SwiftUI
+
+extension GlucoseAlerts {
+    final class StateModel: BaseStateModel<Provider> {
+        @Injected() var fetchGlucoseManager: FetchGlucoseManager!
+
+        @Published var units: GlucoseUnits = .mgdL
+        @Published var glucoseBadge = false
+        @Published var cgmAppInfo: CGMManagerAlertOwnership.OwningApp?
+
+        var cgmProvidesOwnAlerts: Bool { cgmAppInfo != nil }
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+            refreshCGMOwnership()
+            subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
+        }
+
+        /// Re-read whether the active CGM owns its own glucose alerts. The
+        /// active manager can change while the user is in CGM settings, so
+        /// the alarms view calls this on appear.
+        func refreshCGMOwnership() {
+            cgmAppInfo = CGMManagerAlertOwnership.owningApp(
+                manager: fetchGlucoseManager?.cgmManager,
+                sourceType: fetchGlucoseManager?.cgmGlucoseSourceType ?? .none
+            )
+        }
+    }
+}
+
+extension GlucoseAlerts.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {
+        units = settingsManager.settings.units
+    }
+}

+ 87 - 0
Trio/Sources/Modules/GlucoseAlerts/View/AddGlucoseAlertSheet.swift

@@ -0,0 +1,87 @@
+import SwiftUI
+
+struct AddGlucoseAlertSheet: View {
+    @ObservedObject var store: GlucoseAlertsStore
+    let onPick: (GlucoseAlertType) -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+
+    var body: some View {
+        NavigationStack {
+            List {
+                if !availableTypes.isEmpty {
+                    Section(header: Text("Available")) {
+                        ForEach(availableTypes) { type in
+                            availableRow(for: type)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !unavailableTypes.isEmpty {
+                    Section(
+                        header: Text("Unavailable"),
+                        footer: Text(
+                            "Already set for Day & Night. Change the alarm time window to add a second alarm for the same type."
+                        )
+                    ) {
+                        ForEach(unavailableTypes) { type in
+                            unavailableRow(for: type)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Add Alarm")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) { dismiss() }
+                }
+            }
+        }
+    }
+
+    private var availableTypes: [GlucoseAlertType] {
+        GlucoseAlertType.allCases.filter { !store.availableActiveOptions(forNewAlarmOfType: $0).isEmpty }
+    }
+
+    private var unavailableTypes: [GlucoseAlertType] {
+        GlucoseAlertType.allCases.filter { store.availableActiveOptions(forNewAlarmOfType: $0).isEmpty }
+    }
+
+    private func availableRow(for type: GlucoseAlertType) -> some View {
+        Button {
+            onPick(type)
+            dismiss()
+        } label: {
+            HStack(spacing: 12) {
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(type.displayName)
+                        .foregroundColor(.primary)
+                    Text(type.blurb)
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+                Spacer()
+                Image(systemName: "chevron.right")
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+        }
+    }
+
+    private func unavailableRow(for type: GlucoseAlertType) -> some View {
+        HStack(spacing: 12) {
+            VStack(alignment: .leading, spacing: 2) {
+                Text(type.displayName)
+                    .foregroundColor(.primary)
+                Text(type.blurb)
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+            Spacer()
+        }
+        .opacity(0.5)
+    }
+}

+ 23 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmActiveSection.swift

@@ -0,0 +1,23 @@
+import SwiftUI
+
+struct AlarmActiveSection: View {
+    @Binding var activeOption: ActiveOption
+    /// Subset of `ActiveOption` cases the picker is allowed to offer.
+    /// Defaults to all cases when omitted.
+    var allowed: [ActiveOption] = ActiveOption.allCases
+
+    var body: some View {
+        Section(
+            header: Text("Active During"),
+            footer: Text(
+                "Day and Night windows are configured globally on the Alarms screen."
+            )
+        ) {
+            AlarmEnumMenuPicker(
+                title: String(localized: "Active"),
+                selection: $activeOption,
+                allowed: allowed
+            )
+        }.listRowBackground(Color.chart)
+    }
+}

+ 168 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift

@@ -0,0 +1,168 @@
+import AVFoundation
+import SwiftUI
+
+struct AlarmAudioSection: View {
+    @Binding var playsSound: Bool
+    @Binding var soundFilename: String
+
+    @State private var showTonePicker = false
+
+    var body: some View {
+        Section(header: Text("Alert Sound")) {
+            Toggle("Play Sound", isOn: $playsSound)
+
+            if playsSound {
+                Button {
+                    showTonePicker = true
+                } label: {
+                    HStack {
+                        Text("Tone")
+                        Spacer()
+                        Text(AlarmSoundCatalog.displayName(for: soundFilename))
+                            .foregroundColor(.secondary)
+                        Image(systemName: "chevron.right")
+                            .foregroundColor(.secondary)
+                            .font(.footnote)
+                    }
+                }
+                .buttonStyle(.plain)
+                .sheet(isPresented: $showTonePicker) {
+                    TonePickerSheet(selected: $soundFilename)
+                }
+            }
+        }
+        .listRowBackground(Color.chart)
+    }
+}
+
+private struct TonePickerSheet: View {
+    @Binding var selected: String
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @StateObject private var previewer = SoundPreviewPlayer()
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section {
+                    ForEach(AlarmSoundCatalog.allFilenames, id: \.self) { filename in
+                        TonePickerRow(
+                            filename: filename,
+                            selected: $selected,
+                            previewer: previewer
+                        )
+                    }
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Choose Tone")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Done") {
+                        previewer.stop()
+                        dismiss()
+                    }
+                }
+            }
+            .onDisappear { previewer.stop() }
+        }
+    }
+}
+
+private struct TonePickerRow: View {
+    let filename: String
+    @Binding var selected: String
+    @ObservedObject var previewer: SoundPreviewPlayer
+
+    var body: some View {
+        HStack(spacing: 12) {
+            Button {
+                selected = filename
+            } label: {
+                Text(AlarmSoundCatalog.displayName(for: filename))
+                    .foregroundColor(.primary)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+
+            Button {
+                previewer.toggle(filename: filename)
+            } label: {
+                Image(systemName: isPlaying ? "stop.circle.fill" : "play.circle.fill")
+                    .font(.title3)
+                    .foregroundColor(.accentColor)
+            }
+            .buttonStyle(.plain)
+
+            Spacer()
+
+            Image(systemName: "checkmark")
+                .foregroundColor(.accentColor)
+                .opacity(filename == selected ? 1 : 0)
+                .frame(width: 20)
+        }
+    }
+
+    private var isPlaying: Bool { previewer.playing == filename }
+}
+
+/// Plays bundled alarm `.caf` files for in-picker auditioning. Mixable
+/// `.playback` session so it ducks other audio without taking it over.
+/// Not the critical-alert player — this one obeys the silent switch.
+@MainActor private final class SoundPreviewPlayer: ObservableObject {
+    @Published private(set) var playing: String?
+    private var player: AVAudioPlayer?
+
+    func toggle(filename: String) {
+        if playing == filename {
+            stop()
+        } else {
+            play(filename: filename)
+        }
+    }
+
+    func play(filename: String) {
+        stop()
+        let resource = (filename as NSString).deletingPathExtension
+        let ext = (filename as NSString).pathExtension.isEmpty ? "caf" : (filename as NSString).pathExtension
+        guard let url = Bundle.main.url(forResource: resource, withExtension: ext, subdirectory: "Sounds") else {
+            return
+        }
+        do {
+            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers])
+            try AVAudioSession.sharedInstance().setActive(true, options: [])
+            let p = try AVAudioPlayer(contentsOf: url)
+            p.delegate = previewDelegate
+            p.prepareToPlay()
+            guard p.play() else { return }
+            player = p
+            playing = filename
+        } catch {
+            debug(.service, "Audio preview failed for \(filename): \(error)")
+        }
+    }
+
+    func stop() {
+        player?.stop()
+        player = nil
+        playing = nil
+        try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
+    }
+
+    private lazy var previewDelegate: PreviewDelegate = {
+        let delegate = PreviewDelegate()
+        delegate.onFinish = { [weak self] in self?.stop() }
+        return delegate
+    }()
+}
+
+private final class PreviewDelegate: NSObject, AVAudioPlayerDelegate {
+    var onFinish: (() -> Void)?
+
+    func audioPlayerDidFinishPlaying(_: AVAudioPlayer, successfully _: Bool) {
+        Task { @MainActor in self.onFinish?() }
+    }
+}

+ 54 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmBGSection.swift

@@ -0,0 +1,54 @@
+import SwiftUI
+
+struct AlarmBGSection: View {
+    let header: String
+    let footer: String?
+    let title: String
+    let range: ClosedRange<Decimal>
+    let step: Decimal
+    let units: GlucoseUnits
+    @Binding var valueMgDL: Decimal
+
+    @State private var showPicker = false
+
+    var body: some View {
+        Section(
+            header: Text(header),
+            footer: footer.map { Text($0) }
+        ) {
+            VStack(spacing: 0) {
+                HStack {
+                    Text(title)
+                    Spacer()
+                    Text(valueMgDL.formatted(for: units))
+                        .foregroundColor(showPicker ? .accentColor : .primary)
+                    Text(units.rawValue)
+                        .foregroundColor(.secondary)
+                }
+                .contentShape(Rectangle())
+                .onTapGesture { showPicker.toggle() }
+
+                if showPicker {
+                    Picker(title, selection: $valueMgDL) {
+                        ForEach(pickerValues, id: \.self) { value in
+                            Text(value.formatted(for: units)).tag(value)
+                        }
+                    }
+                    .pickerStyle(.wheel)
+                    .frame(maxWidth: .infinity)
+                }
+            }
+        }.listRowBackground(Color.chart)
+    }
+
+    private var pickerValues: [Decimal] {
+        let setting = PickerSetting(
+            value: valueMgDL,
+            step: step,
+            min: range.lowerBound,
+            max: range.upperBound,
+            type: .glucose
+        )
+        return PickerSettingsProvider.shared.generatePickerValues(from: setting, units: units)
+    }
+}

+ 39 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmEnumMenuPicker.swift

@@ -0,0 +1,39 @@
+import SwiftUI
+
+struct AlarmEnumMenuPicker<E: CaseIterable & Hashable & DayNightDisplayable>: View {
+    let title: String
+    @Binding var selection: E
+    let allowed: [E]
+
+    init(title: String, selection: Binding<E>, allowed: [E]) {
+        self.title = title
+        _selection = selection
+        self.allowed = allowed
+    }
+
+    init(title: String, selection: Binding<E>) where E.AllCases: RandomAccessCollection {
+        self.title = title
+        _selection = selection
+        allowed = Array(E.allCases)
+    }
+
+    var body: some View {
+        HStack {
+            Text(title)
+            Spacer()
+            Picker("", selection: $selection) {
+                ForEach(allowed, id: \.self) { option in
+                    Text(option.displayName).tag(option)
+                }
+            }
+            .pickerStyle(.menu)
+            .onAppear(perform: validate)
+            .onChange(of: allowed) { _, _ in validate() }
+        }
+    }
+
+    private func validate() {
+        guard !allowed.contains(selection), let first = allowed.first else { return }
+        selection = first
+    }
+}

+ 47 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmGramsSection.swift

@@ -0,0 +1,47 @@
+import SwiftUI
+
+/// Mirror of `AlarmBGSection` but for gram-valued thresholds (carbsRequired).
+/// Single fixed unit, no mg/dL ↔︎ mmol/L conversion.
+struct AlarmGramsSection: View {
+    let header: String
+    let footer: String?
+    let title: String
+    let range: ClosedRange<Int>
+    let step: Int
+    @Binding var valueGrams: Decimal
+
+    @State private var showPicker = false
+
+    var body: some View {
+        Section(
+            header: Text(header),
+            footer: footer.map { Text($0) }
+        ) {
+            VStack(spacing: 0) {
+                HStack {
+                    Text(title)
+                    Spacer()
+                    Text("\(Int(NSDecimalNumber(decimal: valueGrams).intValue))")
+                        .foregroundColor(showPicker ? .accentColor : .primary)
+                    Text(String(localized: "g", comment: "Abbreviation for grams"))
+                        .foregroundColor(.secondary)
+                }
+                .contentShape(Rectangle())
+                .onTapGesture { showPicker.toggle() }
+
+                if showPicker {
+                    Picker(title, selection: Binding(
+                        get: { Int(NSDecimalNumber(decimal: valueGrams).intValue) },
+                        set: { valueGrams = Decimal($0) }
+                    )) {
+                        ForEach(Array(stride(from: range.lowerBound, through: range.upperBound, by: step)), id: \.self) { v in
+                            Text("\(v)").tag(v)
+                        }
+                    }
+                    .pickerStyle(.wheel)
+                    .frame(maxWidth: .infinity)
+                }
+            }
+        }.listRowBackground(Color.chart)
+    }
+}

+ 44 - 0
Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmWindowIcon.swift

@@ -0,0 +1,44 @@
+import SwiftUI
+
+/// Small SF Symbol that visually conveys an alarm's `ActiveOption` window:
+/// sun for day-only, moon-and-stars for night-only, overlapped pair for both.
+/// The frame is locked to the widest variant (the overlapped pair) so alarm
+/// titles don't shift horizontally when alarms with different windows are
+/// listed together.
+struct AlarmWindowIcon: View {
+    let option: ActiveOption
+
+    var body: some View {
+        ZStack {
+            // Hidden width anchor — always the widest variant. `.hidden()`
+            // keeps it in the layout but invisible, so the frame size scales
+            // with the caller's font (Dynamic Type-friendly) without
+            // hardcoding a width.
+            HStack(spacing: -10) {
+                Image(systemName: "sun.max.fill")
+                Image(systemName: "moon.stars.fill")
+            }
+            .hidden()
+
+            content
+        }
+    }
+
+    @ViewBuilder private var content: some View {
+        switch option {
+        case .day:
+            Image(systemName: "sun.max.fill")
+                .foregroundStyle(.orange)
+        case .night:
+            Image(systemName: "moon.stars.fill")
+                .foregroundStyle(.indigo)
+        case .always:
+            HStack(spacing: -10) {
+                Image(systemName: "sun.max.fill")
+                    .foregroundStyle(.orange)
+                Image(systemName: "moon.stars.fill")
+                    .foregroundStyle(.indigo)
+            }
+        }
+    }
+}

+ 191 - 0
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift

@@ -0,0 +1,191 @@
+import SwiftUI
+
+struct GlucoseAlertEditorView: View {
+    @ObservedObject var store: GlucoseAlertsStore
+    let alertID: UUID
+    let isNew: Bool
+
+    /// Windows the user can still pick without overlapping another alarm of
+    /// the same type. The being-edited alarm is excluded from "taken" so its
+    /// current option stays valid.
+    private var allowedActiveOptions: [ActiveOption] {
+        let available = store.availableActiveOptions(
+            forType: working.type,
+            excludingAlertID: isNew ? nil : alertID
+        )
+        return ActiveOption.allCases.filter { available.contains($0) }
+    }
+
+    let units: GlucoseUnits
+    var onDone: () -> Void
+    var onCancel: () -> Void
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.colorScheme) private var colorScheme
+    @Environment(AppState.self) private var appState
+    @State private var working: GlucoseAlert
+
+    init(
+        store: GlucoseAlertsStore,
+        initial: GlucoseAlert,
+        isNew: Bool,
+        units: GlucoseUnits,
+        onDone: @escaping () -> Void,
+        onCancel: @escaping () -> Void = {}
+    ) {
+        self.store = store
+        alertID = initial.id
+        self.isNew = isNew
+        self.units = units
+        self.onDone = onDone
+        self.onCancel = onCancel
+        _working = State(initialValue: initial)
+    }
+
+    var body: some View {
+        NavigationStack {
+            Form {
+                generalSection
+
+                switch working.type {
+                case .urgentLow: urgentLowBody
+                case .low: lowBody
+                case .forecastedLow: forecastedLowBody
+                case .high: highBody
+                case .carbsRequired: carbsRequiredBody
+                }
+
+                AlarmActiveSection(
+                    activeOption: $working.activeOption,
+                    allowed: allowedActiveOptions
+                )
+                AlarmAudioSection(
+                    playsSound: $working.playsSound,
+                    soundFilename: $working.soundFilename
+                )
+
+                if !isNew, store.canDelete(working) {
+                    Section {
+                        Button(role: .destructive) {
+                            store.remove(working)
+                            dismiss()
+                        } label: {
+                            Text("Delete Alarm")
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle(working.type.displayName)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button(isNew ? String(localized: "Add") : String(localized: "Done")) {
+                        if isNew {
+                            store.add(working)
+                        } else {
+                            store.update(working)
+                        }
+                        onDone()
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) {
+                        onCancel()
+                        dismiss()
+                    }
+                }
+            }
+        }
+    }
+
+    // MARK: - Sections
+
+    private var generalSection: some View {
+        Section(
+            header: Text("General"),
+            footer: Text(working.type.blurb)
+        ) {
+            TextField(String(localized: "Name"), text: $working.name)
+            // Urgent-low is the safety floor — the user can mute the sound
+            // from the Audio section but the alarm itself is always armed.
+            if working.type != .urgentLow {
+                Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+            }
+            Toggle(
+                String(localized: "Override Silence & Focus Mode"),
+                isOn: $working.overridesSilenceAndDND
+            )
+        }.listRowBackground(Color.chart)
+    }
+
+    private var urgentLowBody: some View {
+        AlarmBGSection(
+            header: String(localized: "Urgent Low Threshold"),
+            footer: String(
+                localized: "Recommended to always override silence and Focus mode."
+            ),
+            title: String(localized: "Glucose"),
+            range: 54 ... 80,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var lowBody: some View {
+        AlarmBGSection(
+            header: String(localized: "Low Threshold"),
+            footer: String(
+                localized: "Fires when glucose is at or below this value."
+            ),
+            title: String(localized: "Glucose"),
+            range: 54 ... 100,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var forecastedLowBody: some View {
+        AlarmBGSection(
+            header: String(localized: "Low Threshold"),
+            footer: String(
+                localized: "Fires when the forecast at +20 minutes (blended across all available prediction curves) is at or below this value."
+            ),
+            title: String(localized: "Glucose"),
+            range: 54 ... 100,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var highBody: some View {
+        AlarmBGSection(
+            header: String(localized: "High Threshold"),
+            footer: String(
+                localized: "Fires when glucose is at or above this value."
+            ),
+            title: String(localized: "Glucose"),
+            range: 100 ... 400,
+            step: 1,
+            units: units,
+            valueMgDL: $working.thresholdMgDL
+        )
+    }
+
+    private var carbsRequiredBody: some View {
+        AlarmGramsSection(
+            header: String(localized: "Carbs Required Threshold"),
+            footer: String(
+                localized: "Fires when the algorithm suggests to eat at least this many grams of carbs to avoid a low."
+            ),
+            title: String(localized: "Carbs"),
+            range: 5 ... 50,
+            step: 1,
+            valueGrams: $working.thresholdMgDL
+        )
+    }
+}

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

@@ -0,0 +1,344 @@
+import SwiftUI
+import Swinject
+
+private enum AlertSheet: Identifiable {
+    case picker
+    case editor(GlucoseAlert, isNew: Bool)
+
+    var id: String {
+        switch self {
+        case .picker: return "picker"
+        case let .editor(alert, _): return alert.id.uuidString
+        }
+    }
+}
+
+extension GlucoseAlerts {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = GlucoseAlertsStore.shared
+
+        @State private var sheet: AlertSheet?
+        @State private var pendingNewType: GlucoseAlertType?
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        @State private var shouldDisplayHint: Bool = false
+        @State var hintDetent = PresentationDetent.large
+        @State var selectedVerboseHint: AnyView?
+        @State var hintLabel: String?
+        @State private var decimalPlaceholder: Decimal = 0.0
+        @State private var booleanPlaceholder: Bool = false
+        @State private var displayPickerLowGlucose: Bool = false
+        @State private var displayPickerHighGlucose: Bool = false
+
+        var body: some View {
+            List {
+                if !enabledAlerts.isEmpty {
+                    Section(header: Text("Enabled")) {
+                        ForEach(enabledAlerts) { alarm in
+                            row(for: alarm)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !disabledAlerts.isEmpty {
+                    Section(header: Text("Disabled")) {
+                        ForEach(disabledAlerts) { alarm in
+                            row(for: alarm).opacity(0.6)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !cgmHandledAlerts.isEmpty {
+                    Section(
+                        header: Text("Handled by CGM App"),
+                        footer: cgmHandledFooter
+                    ) {
+                        ForEach(cgmHandledAlerts) { alarm in
+                            row(for: alarm).opacity(0.6)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+
+                // FIXME: make this into a nice setting with mini and verbose hint
+                Section {
+                    Text("Day & Night Windows")
+                        .navigationLink(to: .alarmWindows, from: self)
+                }.listRowBackground(Color.chart)
+
+                Section(footer: Text(useCGMAlertsFooter)) {
+                    Toggle(isOn: Binding(
+                        // When the active CGM has no companion app to defer
+                        // to, force the visible state OFF regardless of the
+                        // stored preference — there's nothing for the toggle
+                        // to control, so showing it ON would mislead.
+                        get: {
+                            guard state.cgmProvidesOwnAlerts else { return false }
+                            return !store.configuration.forceTrioAlertsWhenCGMProvidesOwn
+                        },
+                        set: { store.configuration.forceTrioAlertsWhenCGMProvidesOwn = !$0 }
+                    )) {
+                        Text("Use CGM App Alerts")
+                    }
+                    .disabled(!state.cgmProvidesOwnAlerts)
+                }.listRowBackground(Color.chart)
+
+                SettingInputSection(
+                    decimalValue: $decimalPlaceholder,
+                    booleanValue: $state.glucoseBadge,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    selectedVerboseHint: Binding(
+                        get: { selectedVerboseHint },
+                        set: {
+                            selectedVerboseHint = $0.map { AnyView($0) }
+                            hintLabel = String(localized: "Show Glucose App Badge")
+                        }
+                    ),
+                    units: state.units,
+                    type: .boolean,
+                    label: String(localized: "Show Glucose App Badge"),
+                    miniHint: String(localized: "Show your current glucose on Trio app icon."),
+                    verboseHint: VStack(alignment: .leading, spacing: 10) {
+                        Text("Default: OFF").bold()
+                        Text(
+                            "This will add your current glucose on the top right of your Trio icon as a red notification badge. Changing setting takes effect on next Glucose reading."
+                        )
+                    },
+                    headerText: String(localized: "Glucose App Badge")
+                )
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Glucose Alarms")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .primaryAction) {
+                    Button { sheet = .picker } label: { Image(systemName: "plus") }
+                }
+            }
+            .sheet(item: $sheet, onDismiss: handleSheetDismiss) { which in
+                switch which {
+                case .picker:
+                    AddGlucoseAlertSheet(store: store) { type in
+                        pendingNewType = type
+                        sheet = nil
+                    }
+                case let .editor(alarm, isNew):
+                    GlucoseAlertEditorView(
+                        store: store,
+                        initial: alarm,
+                        isNew: isNew,
+                        units: state.units,
+                        onDone: { sheet = nil },
+                        onCancel: { sheet = nil }
+                    )
+                }
+            }
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help", comment: "Help sheet title")
+                )
+            }
+            .onAppear {
+                configureView()
+                state.refreshCGMOwnership()
+            }
+        }
+
+        private func handleSheetDismiss() {
+            guard let type = pendingNewType else { return }
+            pendingNewType = nil
+            DispatchQueue.main.async {
+                var seed = GlucoseAlert(type: type)
+                // Default new alarm to the first available window so it
+                // doesn't overlap with whatever is already configured.
+                let available = store.availableActiveOptions(forNewAlarmOfType: type)
+                if let first = ActiveOption.allCases.first(where: available.contains) {
+                    seed.activeOption = first
+                }
+                sheet = .editor(seed, isNew: true)
+            }
+        }
+
+        // MARK: - Sorted lists
+
+        /// Mirror of `GlucoseAlertCoordinator.shouldRespect(alarm:)`'s
+        /// CGM-ownership branch: when "Use CGM App Alerts" is ON and the
+        /// active CGM provides its own glucose alerts, the coordinator
+        /// silences reading-driven types. The view surfaces this by moving
+        /// those alarms into a dedicated section.
+        private var isCGMSuppressionActive: Bool {
+            !store.configuration.forceTrioAlertsWhenCGMProvidesOwn && state.cgmProvidesOwnAlerts
+        }
+
+        private var cgmHandledAlerts: [GlucoseAlert] {
+            guard isCGMSuppressionActive else { return [] }
+            return store.alerts
+                .filter { $0.isEnabled && $0.type.isReadingDriven }
+                .sorted { lhs, rhs in
+                    lhs.type.priority < rhs.type.priority
+                }
+        }
+
+        /// Footer for the "Use CGM App Alerts" toggle. Names the eligible
+        /// CGMs when one of them is active and the user can decide; when
+        /// the active CGM has no companion app the toggle is disabled and
+        /// the footer says Trio is handling alarms.
+        private var useCGMAlertsFooter: String {
+            if state.cgmProvidesOwnAlerts {
+                return String(
+                    localized:
+                    "Your CGM app handles alerts (Dexcom G6 / One, G7 / One+, or xDrip4iOS). Turn off to let Trio alert you."
+                )
+            }
+            return String(
+                localized:
+                "Your CGM has no companion app, so Trio handles alarms."
+            )
+        }
+
+        /// Footer for the "Handled by CGM App" section. Names the specific
+        /// companion app, and renders its name as a deep link when a URL
+        /// scheme is known for that app (see CGMManagerAlertOwnership).
+        @ViewBuilder private var cgmHandledFooter: some View {
+            if let info = state.cgmAppInfo {
+                Text(handledFooterMarkdown(for: info))
+            } else {
+                Text(
+                    "These alarms are silenced because the CGM app handles them. To have Trio notify you instead, turn off \"Use CGM App Alerts\" below."
+                )
+            }
+        }
+
+        private func handledFooterMarkdown(for info: CGMManagerAlertOwnership.OwningApp) -> AttributedString {
+            let body = String(
+                format: String(
+                    localized:
+                    "These alarms are silenced because the %@ app handles CGM alerts. To have Trio notify you instead, turn off \"Use CGM App Alerts\" below."
+                ),
+                "{{NAME}}"
+            )
+            var result = AttributedString(body)
+            if let range = result.range(of: "{{NAME}}") {
+                var name = AttributedString(info.name)
+                if let url = info.deepLink {
+                    name.link = url
+                }
+                result.replaceSubrange(range, with: name)
+            }
+            return result
+        }
+
+        private var enabledAlerts: [GlucoseAlert] {
+            let handled = Set(cgmHandledAlerts.map(\.id))
+            return store.alerts
+                .filter(\.isEnabled)
+                .filter { !handled.contains($0.id) }
+                .sorted { lhs, rhs in
+                    lhs.type.priority < rhs.type.priority
+                }
+        }
+
+        private var disabledAlerts: [GlucoseAlert] {
+            store.alerts
+                .filter { !$0.isEnabled }
+                .sorted { lhs, rhs in
+                    lhs.type.priority < rhs.type.priority
+                }
+        }
+
+        // MARK: - Row
+
+        @ViewBuilder private func row(for alarm: GlucoseAlert) -> some View {
+            Button {
+                sheet = .editor(alarm, isNew: false)
+            } label: {
+                HStack(spacing: 12) {
+                    AlarmWindowIcon(option: alarm.activeOption)
+                        .font(.title3)
+
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(alarm.name)
+                            .foregroundColor(.primary)
+                        Text(summary(for: alarm))
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                        soundSummary(for: alarm)
+                    }
+
+                    Spacer()
+
+                    Image(systemName: "chevron.right")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                if store.canDelete(alarm) {
+                    Button(role: .destructive) {
+                        store.remove(alarm)
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
+                }
+            }
+        }
+
+        private func summary(for alarm: GlucoseAlert) -> String {
+            let comparator: String = {
+                switch alarm.type {
+                case .high: return String(localized: "above")
+                case .carbsRequired: return String(localized: "at least")
+                default: return String(localized: "below")
+                }
+            }()
+            let threshold: String = {
+                // `thresholdMgDL` stores grams for carbsRequired — no
+                // mg/dL ↔ mmol/L conversion and a fixed "g" unit label.
+                if alarm.type == .carbsRequired {
+                    return "\(alarm.thresholdMgDL) \(String(localized: "g", comment: "gram of carbs"))"
+                }
+                return "\(alarm.thresholdMgDL.formatted(for: state.units)) \(state.units.rawValue)"
+            }()
+            let window = AlarmEnumDescription.description(for: alarm.activeOption)
+            return "\(comparator.localizedCapitalized) \(threshold) • \(window)"
+        }
+
+        private func soundSummary(for alarm: GlucoseAlert) -> some View {
+            // 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: alarm.playsSound ? "speaker.wave.2.fill" : "speaker.slash.fill")
+                    Text(alarm.playsSound ? "Sound on" : "Sound off")
+                }
+                if alarm.overridesSilenceAndDND {
+                    Text("·")
+                    HStack(spacing: 4) {
+                        Image(systemName: "bell.badge.fill")
+                        Text("Overrides Focus")
+                    }
+                }
+            }
+            .font(.footnote)
+            .foregroundColor(.secondary)
+        }
+    }
+}
+
+private enum AlarmEnumDescription {
+    static func description(for option: ActiveOption) -> String {
+        switch option {
+        case .always: return String(localized: "Day & Night")
+        case .day: return String(localized: "Day only")
+        case .night: return String(localized: "Night only")
+        }
+    }
+}

+ 0 - 5
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsDataFlow.swift

@@ -1,5 +0,0 @@
-enum GlucoseNotificationSettings {
-    enum Config {}
-}
-
-protocol GlucoseNotificationSettingsProvider {}

+ 0 - 3
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsProvider.swift

@@ -1,3 +0,0 @@
-extension GlucoseNotificationSettings {
-    final class Provider: BaseProvider, GlucoseNotificationSettingsProvider {}
-}

+ 0 - 53
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift

@@ -1,53 +0,0 @@
-import SwiftUI
-
-extension GlucoseNotificationSettings {
-    final class StateModel: BaseStateModel<Provider> {
-        @Published var glucoseBadge = false
-        @Published var glucoseNotificationsOption: GlucoseNotificationsOption = .onlyAlarmLimits
-        @Published var addSourceInfoToGlucoseNotifications = false
-        @Published var lowGlucose: Decimal = 0
-        @Published var highGlucose: Decimal = 0
-
-        @Published var notificationsPump = true
-        @Published var notificationsCgm = true
-        @Published var notificationsCarb = true
-        @Published var notificationsAlgorithm = true
-
-        var units: GlucoseUnits = .mgdL
-
-        override func subscribe() {
-            let units = settingsManager.settings.units
-            self.units = units
-
-            subscribeSetting(\.notificationsPump, on: $notificationsPump) { notificationsPump = $0 }
-            subscribeSetting(\.notificationsCgm, on: $notificationsCgm) { notificationsCgm = $0 }
-            subscribeSetting(\.notificationsCarb, on: $notificationsCarb) { notificationsCarb = $0 }
-            subscribeSetting(\.notificationsAlgorithm, on: $notificationsAlgorithm) { notificationsAlgorithm = $0 }
-
-            subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
-            subscribeSetting(\.glucoseNotificationsOption, on: $glucoseNotificationsOption) { glucoseNotificationsOption = $0 }
-            subscribeSetting(\.addSourceInfoToGlucoseNotifications, on: $addSourceInfoToGlucoseNotifications) {
-                addSourceInfoToGlucoseNotifications = $0 }
-
-            subscribeSetting(\.lowGlucose, on: $lowGlucose, initial: {
-                lowGlucose = $0
-            }, map: {
-                let clampedValue = max(min($0, 400), 40)
-                return clampedValue
-            })
-
-            subscribeSetting(\.highGlucose, on: $highGlucose, initial: {
-                highGlucose = $0
-            }, map: {
-                let clampedValue = max(min($0, 400), 40)
-                return clampedValue
-            })
-        }
-    }
-}
-
-extension GlucoseNotificationSettings.StateModel: SettingsObserver {
-    func settingsDidChange(_: TrioSettings) {
-        units = settingsManager.settings.units
-    }
-}

+ 0 - 389
Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift

@@ -1,389 +0,0 @@
-import ActivityKit
-import Combine
-import SwiftUI
-import Swinject
-
-extension GlucoseNotificationSettings {
-    struct RootView: BaseView {
-        let resolver: Resolver
-        @StateObject var state = StateModel()
-
-        @State private var shouldDisplayHint: Bool = false
-        @State var hintDetent = PresentationDetent.large
-        @State var selectedVerboseHint: AnyView?
-        @State var hintLabel: String?
-        @State private var decimalPlaceholder: Decimal = 0.0
-        @State private var booleanPlaceholder: Bool = false
-        @State private var displayPickerLowGlucose: Bool = false
-        @State private var displayPickerHighGlucose: Bool = false
-
-        private var glucoseFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            if state.units == .mmolL {
-                formatter.maximumFractionDigits = 1
-            }
-            formatter.roundingMode = .halfUp
-            return formatter
-        }
-
-        private var carbsFormatter: NumberFormatter {
-            let formatter = NumberFormatter()
-            formatter.numberStyle = .decimal
-            formatter.maximumFractionDigits = 0
-            return formatter
-        }
-
-        @Environment(\.colorScheme) var colorScheme
-        @Environment(AppState.self) var appState
-
-        var body: some View {
-            List {
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsPump,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Pump")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Pump"),
-                    miniHint: String(localized: "Always Notify Pump Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Pump Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a Pump Warning is 'Pod Expiration Reminder'")
-                    },
-                    headerText: String(localized: "Trio Information Notifications")
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsCgm,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify CGM")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify CGM"),
-                    miniHint: String(localized: "Always Notify CGM Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most CGM Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a CGM Warning is 'Unable to open the app'")
-                    }
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsCarb,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Carb")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Carb"),
-                    miniHint: String(localized: "Always Notify Carb Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Carb Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a Carb Warning is 'Carbs required: 30 g'")
-                    }
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsAlgorithm,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Algorithm")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Algorithm"),
-                    miniHint: String(localized: "Always Notify Algorithm Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Algorithm Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text(
-                            "An example of an Algorithm Warning is 'Error: Invalid glucose: Not enough glucose data'"
-                        )
-                    }
-                )
-
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.glucoseBadge,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Show Glucose App Badge")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Show Glucose App Badge"),
-                    miniHint: String(localized: "Show your current glucose on Trio app icon."),
-                    verboseHint: VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: OFF").bold()
-                        Text(
-                            "This will add your current glucose on the top right of your Trio icon as a red notification badge. Changing setting takes effect on next Glucose reading."
-                        )
-                    },
-                    headerText: String(localized: "Various Glucose Notifications")
-                )
-
-                Section {
-                    VStack {
-                        Picker(
-                            selection: $state.glucoseNotificationsOption,
-                            label: Text("Glucose Notifications")
-                        ) {
-                            ForEach(GlucoseNotificationsOption.allCases) { selection in
-                                Text(selection.displayName).tag(selection)
-                            }
-                        }.padding(.top)
-
-                        HStack(alignment: .center) {
-                            Text(
-                                "Choose glucose notifications option. See hint for more details."
-                            )
-                            .font(.footnote)
-                            .foregroundColor(.secondary)
-                            .lineLimit(nil)
-                            Spacer()
-                            Button(
-                                action: {
-                                    hintLabel = String(localized: "Glucose Notifications")
-                                    selectedVerboseHint =
-                                        AnyView(
-                                            VStack(alignment: .leading, spacing: 10) {
-                                                Text(
-                                                    "Set the Glucose Notifications Option. Descriptions for each option found below."
-                                                )
-                                                VStack(alignment: .leading, spacing: 5) {
-                                                    Text("Disabled:").bold()
-                                                    Text("No Glucose Notifications will be triggered.")
-                                                }
-                                                VStack(alignment: .leading, spacing: 5) {
-                                                    Text("Always:").bold()
-                                                    Text(
-                                                        "A notification will be triggered every time your glucose is updated in Trio."
-                                                    )
-                                                }
-                                                VStack(alignment: .leading, spacing: 5) {
-                                                    Text("Only Alarm Limits:").bold()
-                                                    Text(
-                                                        "A notification will be triggered only when glucose levels are below the LOW limit or above the HIGH limit, as specified in Glucose Alarm Limits below."
-                                                    )
-                                                }
-                                            }
-                                        )
-                                    shouldDisplayHint.toggle()
-                                },
-                                label: {
-                                    HStack {
-                                        Image(systemName: "questionmark.circle")
-                                    }
-                                }
-                            ).buttonStyle(BorderlessButtonStyle())
-                        }.padding(.top)
-                    }.padding(.bottom)
-                }.listRowBackground(Color.chart)
-
-                if state.glucoseNotificationsOption != GlucoseNotificationsOption.disabled {
-                    self.lowAndHighGlucoseAlertSection
-                    SettingInputSection(
-                        decimalValue: $decimalPlaceholder,
-                        booleanValue: $state.addSourceInfoToGlucoseNotifications,
-                        shouldDisplayHint: $shouldDisplayHint,
-                        selectedVerboseHint: Binding(
-                            get: { selectedVerboseHint },
-                            set: {
-                                selectedVerboseHint = $0.map { AnyView($0) }
-                                hintLabel = String(localized: "Add Glucose Source to Alarm")
-                            }
-                        ),
-                        units: state.units,
-                        type: .boolean,
-                        label: String(localized: "Add Glucose Source to Alarm"),
-                        miniHint: String(localized: "Source of the glucose reading will be added to the notification."),
-                        verboseHint: VStack(alignment: .leading, spacing: 10) {
-                            Text("Default: OFF").bold()
-                            Text("The source of the glucose reading will be added to the notification.")
-                        }
-                    )
-                }
-            }
-            .listSectionSpacing(sectionSpacing)
-            .sheet(isPresented: $shouldDisplayHint) {
-                SettingInputHintView(
-                    hintDetent: $hintDetent,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    hintLabel: hintLabel ?? "",
-                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
-                    sheetTitle: String(localized: "Help", comment: "Help sheet title")
-                )
-            }
-            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
-            .onAppear(perform: configureView)
-            .navigationBarTitle("Trio Notifications")
-            .navigationBarTitleDisplayMode(.automatic)
-            .settingsHighlightScroll()
-        }
-
-        var lowAndHighGlucoseAlertSection: some View {
-            Section {
-                VStack {
-                    VStack {
-                        HStack {
-                            Text("Low Glucose Alarm Limit")
-
-                            Spacer()
-
-                            Group {
-                                Text(
-                                    state.units == .mgdL ? state.lowGlucose.description : state.lowGlucose.formattedAsMmolL
-                                )
-                                .foregroundColor(!displayPickerLowGlucose ? .primary : .accentColor)
-
-                                Text(state.units == .mgdL ? " mg/dL" : " mmol/L").foregroundColor(.secondary)
-                            }
-                        }
-                        .onTapGesture {
-                            displayPickerLowGlucose.toggle()
-                        }
-                    }
-                    .padding(.top)
-
-                    if displayPickerLowGlucose {
-                        let setting = PickerSettingsProvider.shared.settings.lowGlucose
-
-                        Picker(selection: $state.lowGlucose, label: Text("")) {
-                            ForEach(
-                                PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units),
-                                id: \.self
-                            ) { value in
-                                let displayValue = state.units == .mgdL ? value.description : value.formattedAsMmolL
-                                Text(displayValue).tag(value)
-                            }
-                        }
-                        .pickerStyle(WheelPickerStyle())
-                        .frame(maxWidth: .infinity)
-                    }
-
-                    VStack {
-                        HStack {
-                            Text("High Glucose Alarm Limit")
-
-                            Spacer()
-
-                            Group {
-                                Text(
-                                    state.units == .mgdL ? state.highGlucose.description : state.highGlucose.formattedAsMmolL
-                                )
-                                .foregroundColor(!displayPickerHighGlucose ? .primary : .accentColor)
-
-                                Text(state.units == .mgdL ? " mg/dL" : " mmol/L").foregroundColor(.secondary)
-                            }
-                        }
-                        .onTapGesture {
-                            displayPickerHighGlucose.toggle()
-                        }
-                    }
-                    .padding(.top)
-
-                    if displayPickerHighGlucose {
-                        let setting = PickerSettingsProvider.shared.settings.highGlucose
-                        Picker(selection: $state.highGlucose, label: Text("")) {
-                            ForEach(
-                                PickerSettingsProvider.shared.generatePickerValues(from: setting, units: state.units),
-                                id: \.self
-                            ) { value in
-                                let displayValue = state.units == .mgdL ? value.description : value.formattedAsMmolL
-                                Text(displayValue).tag(value)
-                            }
-                        }
-                        .pickerStyle(WheelPickerStyle())
-                        .frame(maxWidth: .infinity)
-                    }
-
-                    HStack(alignment: .center) {
-                        Text(
-                            "Sets the lower and upper limit for glucose alarms."
-                        )
-                        .lineLimit(nil)
-                        .font(.footnote)
-                        .foregroundColor(.secondary)
-                        Spacer()
-                        Button(
-                            action: {
-                                hintLabel = String(localized: "Low and High Glucose Alarm Limits")
-                                selectedVerboseHint =
-                                    AnyView(VStack(alignment: .leading, spacing: 10) {
-                                        let low: Decimal = 70
-                                        let high: Decimal = 180
-                                        let labelLow = (state.units == .mgdL ? low.description : low.formattedAsMmolL) + " " +
-                                            state.units.rawValue
-                                        let labelHigh = (state.units == .mgdL ? high.description : high.formattedAsMmolL) + " " +
-                                            state.units.rawValue
-                                        Text("Low Default: " + labelLow).bold()
-                                        Text("High Default: " + labelHigh).bold()
-                                        VStack(alignment: .leading, spacing: 10) {
-                                            Text(
-                                                "These two settings determine the range outside of which you will be notified via push notifications."
-                                            )
-                                            Text(
-                                                "If your CGM readings are below the Low value or above the High value, you will receive a glucose alarm."
-                                            )
-                                        }
-                                    })
-                                shouldDisplayHint.toggle()
-                            },
-                            label: {
-                                HStack {
-                                    Image(systemName: "questionmark.circle")
-                                }
-                            }
-                        ).buttonStyle(BorderlessButtonStyle())
-                    }.padding(.top)
-                }.padding(.bottom)
-            }.listRowBackground(Color.chart)
-        }
-    }
-}

+ 3 - 0
Trio/Sources/Modules/Home/HomeProvider.swift

@@ -13,6 +13,9 @@ extension Home {
         }
 
         func heartbeatNow() {
+            // User-initiated force-loop: bypass dwell suppression so transient
+            // errors surface immediately rather than waiting for count/dwell.
+            apsManager.markNextLoopUserInitiated()
             apsManager.heartbeat(date: Date())
         }
 

+ 1 - 1
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -356,7 +356,7 @@ extension Home {
                 .map { [weak self] error in
                     self?.errorDate = error == nil ? nil : Date()
                     if let error = error {
-                        info(.default, String(describing: error), notificationText: error.localizedDescription)
+                        debug(.default, "APSManager lastError: \(String(describing: error))")
                     }
                     return error?.localizedDescription
                 }

+ 2 - 2
Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift

@@ -220,9 +220,9 @@ struct LiveActivityWidgetConfiguration: BaseView {
                 let pointMarkColor = Trio.getDynamicGlucoseColor(
                     glucoseValue: Decimal(data.glucoseLevel),
                     highGlucoseColorValue: !(state.settingsManager.settings.glucoseColorScheme == .dynamicColor) ? state
-                        .settingsManager.settings.highGlucose : Decimal(220),
+                        .settingsManager.settings.high : Decimal(220),
                     lowGlucoseColorValue: !(state.settingsManager.settings.glucoseColorScheme == .dynamicColor) ? state
-                        .settingsManager.settings.lowGlucose : Decimal(55),
+                        .settingsManager.settings.low : Decimal(55),
                     targetGlucose: Decimal(100),
                     glucoseColorScheme: state.settingsManager.settings.glucoseColorScheme
                 )

+ 1 - 47
Trio/Sources/Modules/Main/MainStateModel.swift

@@ -200,49 +200,6 @@ extension Main {
             SwiftMessages.show(config: config, view: view)
         }
 
-        /*
-          Reclassification is needed for Medtronic pumps for 'Pump error:' RileyLink related messages.
-          For details, see https://discord.com/channels/1020905149037813862/1338245444186279946/1343469793013141525.
-          These messages are repeatedly displayed causing users to simply ignore them.
-          Reclassification of these Info type messages is based on APSManager.APSError enum values.
-          We reclassify APSError.pumpError and APSError.invalidPumpState as MessageType.info and MessageSubtype.pump.
-          This allows the user to disable these messages using using the 'Trio Notification' -> 'Always Notify Pump' setting.
-          MessageType.error messagges are always displayed to the user and the user cannot disable them.
-          Other APSManager.APSError remain as MessageType.info which allows users to disable them
-          using the 'Trio Notification' -> 'Always Notify Algorithm' setting.
-         */
-
-        func reclassifyInfoNotification(_ message: inout MessageContent) {
-            if message.title == "" {
-                switch message.type {
-                case .info:
-                    if message.content.range(of: "error", options: .caseInsensitive) != nil || message.content
-                        .range(of: String(localized: "Error"), options: .caseInsensitive) != nil
-                    {
-                        message.title = String(localized: "Error", comment: "Error title")
-                    } else {
-                        message.title = String(localized: "Info", comment: "Info title")
-                    }
-                    if APSError.pumpWarningMatches(message: message.content) {
-                        message.subtype = .pump
-                        let lastLoopMinutes = Int((Date().timeIntervalSince(apsManager.lastLoopDate) - 30) / 60) + 1
-                        if lastLoopMinutes > 10 {
-                            message.type = .error
-                        }
-                    } else if APSError.pumpErrorMatches(message: message.content) {
-                        message.subtype = .pump
-                        message.type = .error
-                    }
-                case .warning:
-                    message.title = String(localized: "Warning", comment: "Warning title")
-                case .error:
-                    message.title = String(localized: "Error", comment: "Error title")
-                case .other:
-                    message.title = String(localized: "Info", comment: "Info title")
-                }
-            }
-        }
-
         override func subscribe() {
             router.mainModalScreen
                 .map { $0?.modal(resolver: self.resolver!) }
@@ -262,10 +219,7 @@ extension Main {
                 .receive(on: DispatchQueue.main)
                 .sink { message in
                     guard !self.isApnPumpConfigAction(message) else { return }
-                    var reclassifyMessage = message
-                    self.reclassifyInfoNotification(&reclassifyMessage)
-                    guard self.router.allowNotify(reclassifyMessage, self.settingsManager.settings) else { return }
-                    self.showAlertMessage(reclassifyMessage)
+                    self.showAlertMessage(message)
                 }
                 .store(in: &lifetime)
 

+ 5 - 1
Trio/Sources/Modules/Main/View/MainRootView.swift

@@ -9,6 +9,10 @@ extension Main {
         @Environment(\.colorScheme) var colorScheme
         @Environment(AppState.self) var appState
 
+        private var modalScheduler: TrioModalAlertScheduler {
+            resolver.resolve(TrioAlertManager.self)!.modalScheduler
+        }
+
         var body: some View {
             router.view(for: .home)
                 .sheet(item: $state.modal) { modal in
@@ -18,7 +22,7 @@ extension Main {
                 .sheet(item: $state.secondaryModal) { wrapper in
                     wrapper.view
                 }
-
+                .trioAlerts(modalScheduler)
                 .onAppear(perform: configureView)
                 .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
         }

+ 0 - 2
Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift

@@ -732,8 +732,6 @@ extension Onboarding {
             if !isFreshTrioInstall {
                 let providedSettings = settingsProvider.settings
 
-                settingsCopy.lowGlucose = settingsCopy.lowGlucose.clamp(to: providedSettings.lowGlucose)
-                settingsCopy.highGlucose = settingsCopy.highGlucose.clamp(to: providedSettings.highGlucose)
                 settingsCopy.carbsRequiredThreshold = settingsCopy.carbsRequiredThreshold
                     .clamp(to: providedSettings.carbsRequiredThreshold)
                 settingsCopy.individualAdjustmentFactor = settingsCopy.individualAdjustmentFactor

+ 13 - 1
Trio/Sources/Modules/PumpConfig/PumpConfigDataFlow.swift

@@ -5,12 +5,24 @@ import LoopKitUI
 enum PumpConfig {
     enum Config {}
 
-    enum PumpType: Equatable {
+    enum PumpType: Equatable, CaseIterable {
         case minimed
         case omni
         case dana
         case medtrum
         case simulator
+
+        /// `Alert.Identifier.managerIdentifier` emitted by this plugin's
+        /// `PumpManager`. Nil for `.simulator` — no real plugin.
+        var pluginIdentifier: String? {
+            switch self {
+            case .minimed: return "Minimed"
+            case .omni: return "Omni"
+            case .dana: return "Dana"
+            case .medtrum: return "Medtrum"
+            case .simulator: return nil
+            }
+        }
     }
 
     struct PumpInitialSettings {

+ 3 - 1
Trio/Sources/Modules/PumpConfig/PumpConfigStateModel.swift

@@ -1,3 +1,4 @@
+import Foundation
 import LoopKit
 import LoopKitUI
 import SwiftDate
@@ -11,6 +12,7 @@ extension PumpConfig {
         private(set) var initialSettings: PumpInitialSettings = .default
         @Published var hasUnacknowledgedAlert: Bool = false
         @Injected() var bluetoothManager: BluetoothStateManager!
+        @Injected() var trioAlertManager: TrioAlertManager!
 
         override func subscribe() {
             provider.pumpDisplayState
@@ -49,7 +51,7 @@ extension PumpConfig {
         }
 
         func ack() {
-            provider.deviceManager.alertHistoryStorage.broadcastAlertUpdates()
+            trioAlertManager.acknowledgeAllOutstanding()
         }
     }
 }

+ 1 - 0
Trio/Sources/Modules/PumpConfig/View/PumpConfigRootView.swift

@@ -48,6 +48,7 @@ extension PumpConfig {
                                     Spacer()
                                     Button("Acknowledge all alerts") { state.ack() }
                                 }
+                                Spacer()
                             } else {
                                 VStack {
                                     Button {

+ 35 - 15
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -322,24 +322,44 @@ enum SettingItems {
     static let notificationItems = [
         SettingItem(title: "Manage iOS Preferences", view: .notificationSettings),
         SettingItem(
-            title: "Trio Notifications",
-            view: .glucoseNotificationSettings,
+            title: "Glucose Alarms",
+            view: .glucoseAlerts,
             searchContents: [
-                "Always Notify Pump",
-                "Always Notify CGM",
-                "Always Notify Carb",
-                "Always Notify Algorithm",
-                "Show Glucose App Badge",
-                "Glucose Notifications",
-                "Add Glucose Source to Alarm",
-                "Low Glucose Alarm Limit",
-                "High Glucose Alarm Limit"
+                "Urgent Low Glucose",
+                "Low Glucose",
+                "Forecasted Low Glucose",
+                "High Glucose",
+                "Day & Night",
+                "Alert Sound",
+                "Override Silence",
+                "Show Glucose App Badge"
             ],
-            scrollTargetLabels: [
-                "Low Glucose Alarm Limit": "Glucose Notifications",
-                "High Glucose Alarm Limit": "Glucose Notifications"
+            path: ["Notifications", "Glucose Alarms"]
+        ),
+        SettingItem(
+            title: "Device Alarms",
+            view: .deviceAlarms,
+            searchContents: [
+                "Critical",
+                "Time-Sensitive",
+                "Normal",
+                "Occlusion",
+                "Pump Fault",
+                "Hardware Fault",
+                "Reservoir",
+                "Battery",
+                "Bolus Failed",
+                "Pod",
+                "Sensor Failure",
+                "Device Expired"
             ],
-            path: ["Notifications", "Trio Notifications"] // Glucose
+            path: ["Notifications", "Pump Alarms"]
+        ),
+        SettingItem(
+            title: "Day & Night Windows",
+            view: .alarmWindows,
+            searchContents: ["Day Starts", "Night Starts", "Day & Night"],
+            path: ["Notifications", "Day & Night Windows"]
         ),
         SettingItem(
             title: "Live Activity",

+ 8 - 2
Trio/Sources/Modules/Settings/View/Subviews/NotificationsView.swift

@@ -76,8 +76,14 @@ struct NotificationsView: BaseView {
             Section(
                 header: Text("Notification Center"),
                 content: {
-                    Text("Trio Notifications")
-                        .navigationLink(to: .glucoseNotificationSettings, from: self)
+                    Text("Glucose Alarms")
+                        .navigationLink(to: .glucoseAlerts, from: self)
+
+                    Text("Pump Alarms")
+                        .navigationLink(to: .deviceAlarms, from: self)
+
+                    Text("Day & Night Windows")
+                        .navigationLink(to: .alarmWindows, from: self)
 
                     if #available(iOS 16.2, *) {
                         Text("Live Activity").navigationLink(to: .liveActivitySettings, from: self)

+ 0 - 55
Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift

@@ -850,64 +850,9 @@ extension SettingsExport {
                 addSetting(
                     category: notificationsCategory,
                     subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Always Notify Pump"),
-                    value: trioSettings.notificationsPump ? String(localized: "Enabled") : String(localized: "Disabled")
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Always Notify CGM"),
-                    value: trioSettings.notificationsCgm ? String(localized: "Enabled") : String(localized: "Disabled")
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Always Notify Carb"),
-                    value: trioSettings.notificationsCarb ? String(localized: "Enabled") : String(localized: "Disabled")
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Always Notify Algorithm"),
-                    value: trioSettings.notificationsAlgorithm ? String(localized: "Enabled") : String(localized: "Disabled")
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
                     name: String(localized: "Show Glucose App Badge"),
                     value: trioSettings.glucoseBadge ? String(localized: "Enabled") : String(localized: "Disabled")
                 )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Glucose Notifications"),
-                    value: trioSettings.glucoseNotificationsOption.rawValue
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Add Glucose Source to Alarm"),
-                    value: trioSettings
-                        .addSourceInfoToGlucoseNotifications ? String(localized: "Enabled") : String(localized: "Disabled")
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Low Glucose Alarm Limit"),
-                    value: trioSettings
-                        .units == .mgdL ? String(describing: trioSettings.lowGlucose) :
-                        String(describing: trioSettings.lowGlucose.asMmolL),
-                    unit: trioSettings.units.rawValue
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "High Glucose Alarm Limit"),
-                    value: trioSettings
-                        .units == .mgdL ? String(describing: trioSettings.highGlucose) :
-                        String(describing: trioSettings.highGlucose.asMmolL),
-                    unit: trioSettings.units.rawValue
-                )
 
                 // Live Activity subcategory
                 let liveActivitySubcategory = String(localized: "Live Activity")

+ 4 - 2
Trio/Sources/Modules/Snooze/SnoozeStateModel.swift

@@ -28,8 +28,10 @@ extension Snooze {
         }
 
         @MainActor func applySnooze(_ duration: TimeInterval) async {
-            // Allow any duration chosen in the Snooze UI, while keeping validation for quick actions elsewhere.
-            snoozeUntilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
+            // 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)
         }

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

@@ -286,7 +286,7 @@ extension UserInterfaceSettings {
 
                             HStack(alignment: .center) {
                                 Text(
-                                    "Set low and high glucose values for the main screen, watch app and live activity glucose graph."
+                                    "Set low and high glucose values for the main screen, watch app, live activity, and contact image colors."
                                 )
                                 .lineLimit(nil)
                                 .font(.footnote)
@@ -303,7 +303,10 @@ extension UserInterfaceSettings {
                                                         "Default values are based on internationally accepted Time in Range values of \(state.units == .mgdL ? "70" : 70.formattedAsMmolL)-\(state.units == .mgdL ? "180" : 180.formattedAsMmolL) \(state.units.rawValue)."
                                                     ).bold()
                                                     Text(
-                                                        "Adjust these values if you would like the statistics to reflect different values than the internationally accepted Time In Range values used as the default."
+                                                        "These thresholds drive the chart band colors, statistics in-range buckets, Live Activity colors, and Contact Image colors."
+                                                    )
+                                                    Text(
+                                                        "To configure when alarms fire (urgent low / low / high / forecasted low), open Glucose Alarms."
                                                     )
                                                     Text("Note: These values are not used to calculate insulin dosing.")
                                                 }

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

@@ -33,7 +33,6 @@ protocol Router {
     var mainSecondaryModalView: CurrentValueSubject<AnyView?, Never> { get }
     var alertMessage: PassthroughSubject<MessageContent, Never> { get }
     func view(for screen: Screen) -> AnyView
-    func allowNotify(_ message: MessageContent, _ settings: TrioSettings) -> Bool
 }
 
 final class BaseRouter: Router {
@@ -49,27 +48,4 @@ final class BaseRouter: Router {
     func view(for screen: Screen) -> AnyView {
         screen.view(resolver: resolver).asAny()
     }
-
-    func allowNotify(_ message: MessageContent, _ settings: TrioSettings) -> Bool {
-        if message.type == .error { return true }
-        switch message.subtype {
-        case .pump:
-            guard settings.notificationsPump else { return false }
-        case .cgm:
-            guard settings.notificationsCgm else { return false }
-        case .carb:
-            guard settings.notificationsCarb else { return false }
-        case .glucose:
-            guard (
-                message.type == .warning &&
-                    settings.glucoseNotificationsOption == GlucoseNotificationsOption.onlyAlarmLimits
-            ) ||
-                settings.glucoseNotificationsOption == GlucoseNotificationsOption.alwaysEveryCGM else { return false }
-        case .algorithm:
-            guard settings.notificationsAlgorithm else { return false }
-        case .misc:
-            return true
-        }
-        return true
-    }
 }

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

@@ -20,7 +20,9 @@ enum Screen: Identifiable, Hashable {
     case history
     case cgm
     case healthkit
-    case glucoseNotificationSettings
+    case glucoseAlerts
+    case deviceAlarms
+    case alarmWindows
     case mealSettings
     case iconConfig
     case overrideConfig
@@ -105,8 +107,12 @@ extension Screen {
             )
         case .healthkit:
             AppleHealthKit.RootView(resolver: resolver)
-        case .glucoseNotificationSettings:
-            GlucoseNotificationSettings.RootView(resolver: resolver)
+        case .glucoseAlerts:
+            GlucoseAlerts.RootView(resolver: resolver)
+        case .deviceAlarms:
+            DeviceAlarms.RootView(resolver: resolver)
+        case .alarmWindows:
+            AlarmWindows.RootView(resolver: resolver)
         case .mealSettings:
             MealSettings.RootView(resolver: resolver)
         case .iconConfig:

+ 187 - 0
Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift

@@ -0,0 +1,187 @@
+import LoopKit
+
+/// Trio-side bridge of the upstream `AlertCatalogVendor` protocol. Pump
+/// plugins don't currently set `interruptionLevel` on the alerts they issue
+/// — every emission lands at LoopKit's default (`.timeSensitive`). Trio
+/// overrides the level by looking up the alert's identifier in this
+/// 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
+/// (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 func lookup(_ identifier: Alert.Identifier) -> Alert.CatalogEntry? {
+        if let exact = entries.first(where: { $0.identifier == identifier }) {
+            return exact
+        }
+        return omniPodFaultEntry(for: identifier)
+    }
+
+    /// Omni emits pod faults via `notifyPodFault` with a separate manager
+    /// identifier (`Omni:pumpFault`) and a formatted alert identifier
+    /// (`Fault Event Code 0xNN: ...`). Map the small set of user-relevant
+    /// codes to concrete concepts; everything else falls back to a generic
+    /// `Pod Fault` so it still surfaces under the Critical tier.
+    private static func omniPodFaultEntry(for identifier: Alert.Identifier) -> Alert.CatalogEntry? {
+        guard identifier.managerIdentifier == "Omni:pumpFault" else { return nil }
+        let code = parseOmniFaultHexCode(from: identifier.alertIdentifier)
+        switch code {
+        case 0x14:
+            return Alert.CatalogEntry(
+                identifier: identifier, interruptionLevel: .critical,
+                title: "Pod Occlusion", category: "Delivery", concept: .occlusion
+            )
+        case 0x18:
+            return Alert.CatalogEntry(
+                identifier: identifier, interruptionLevel: .critical,
+                title: "Pod Reservoir Empty", category: "Reservoir", concept: .reservoirEmpty
+            )
+        case 0x1C:
+            return Alert.CatalogEntry(
+                identifier: identifier, interruptionLevel: .timeSensitive,
+                title: "Pod Expired", category: "Lifecycle", concept: .deviceExpired
+            )
+        default:
+            return Alert.CatalogEntry(
+                identifier: identifier, interruptionLevel: .critical,
+                title: "Pod Fault", category: "Hardware", concept: .hardwareFault
+            )
+        }
+    }
+
+    /// Extracts the hex code from `Fault Event Code 0xNN: ...`. Bounded
+    /// parser, not a substring classifier — the prefix shape is fixed by
+    /// `FaultEventCode.description`.
+    private static func parseOmniFaultHexCode(from alertID: String) -> UInt8? {
+        let prefix = "Fault Event Code 0x"
+        guard alertID.hasPrefix(prefix), alertID.count >= prefix.count + 2 else { return nil }
+        let start = alertID.index(alertID.startIndex, offsetBy: prefix.count)
+        let end = alertID.index(start, offsetBy: 2)
+        return UInt8(alertID[start ..< end], radix: 16)
+    }
+}
+
+// MARK: - Omnipod (Eros + DASH)
+
+private extension AlertCatalogRegistry {
+    static let omniEntries: [Alert.CatalogEntry] = [
+        addEntry("Omni", "userPodExpiration", .active, "Pod Expiration Reminder", "Lifecycle", .deviceExpirationReminder),
+        addEntry("Omni", "podExpiring", .timeSensitive, "Pod Expired", "Lifecycle", .deviceExpired),
+        addEntry("Omni", "podExpireImminent", .timeSensitive, "Pod Shutdown Imminent", "Lifecycle", .deviceShutdownImminent),
+        addEntry("Omni", "lowReservoir", .timeSensitive, "Low Reservoir", "Reservoir", .reservoirLow),
+        addEntry("Omni", "suspendInProgress", .active, "Suspend In Progress Reminder", "Delivery", .suspendInProgressReminder),
+        addEntry("Omni", "suspendEnded", .timeSensitive, "Resume Insulin", "Delivery", .insulinResumeReminder),
+        addEntry("Omni", "finishSetupReminder", .active, "Pod Pairing Incomplete", "Lifecycle", .setupIncomplete),
+        addEntry("Omni", "unexpectedAlert", .critical, "Unexpected Alert", "Hardware", .hardwareFault),
+        addEntry("Omni", "timeOffsetChangeDetected", .active, "Time Change Detected", "Lifecycle", .timeChange),
+        addEntry("Omni", "lowRLBattery", .timeSensitive, "Low RileyLink Battery", "Battery", .rileyLinkBatteryLow)
+    ]
+}
+
+// MARK: - Minimed (500/700)
+
+private extension AlertCatalogRegistry {
+    static let minimedEntries: [Alert.CatalogEntry] = [
+        addEntry("Minimed", "PumpBatteryLow", .timeSensitive, "Pump Battery Low", "Battery", .pumpBatteryLow),
+        addEntry("Minimed", "PumpReservoirEmpty", .critical, "Pump Reservoir Empty", "Reservoir", .reservoirEmpty),
+        addEntry("Minimed", "PumpReservoirLow", .timeSensitive, "Pump Reservoir Low", "Reservoir", .reservoirLow),
+        addEntry("Minimed", "lowRLBattery", .timeSensitive, "Low RileyLink Battery", "Battery", .rileyLinkBatteryLow)
+    ]
+}
+
+// MARK: - Dana (RS/i/-i)
+
+private extension AlertCatalogRegistry {
+    static let danaEntries: [Alert.CatalogEntry] = [
+        addEntry("Dana", "batteryZeroPercent", .critical, "Pump Battery 0%", "Battery", .pumpBatteryEmpty),
+        addEntry("Dana", "pumpError", .critical, "Pump Error", "Hardware", .hardwareFault),
+        addEntry("Dana", "occlusion", .critical, "Occlusion", "Delivery", .occlusion),
+        addEntry("Dana", "lowBattery", .timeSensitive, "Low Pump Battery", "Battery", .pumpBatteryLow),
+        addEntry("Dana", "shutdown", .critical, "Pump Shutdown", "Hardware", .hardwareFault),
+        addEntry("Dana", "basalCompare", .active, "Basal Compare", "Delivery", .basalProfileMismatch),
+        addEntry("Dana", "bloodSugarMeasure", .active, "Blood Glucose Measure", "Reminders", .userBloodGlucoseReminder),
+        addEntry("Dana", "remainingInsulinLevel", .timeSensitive, "Remaining Insulin Level", "Reservoir", .reservoirLow),
+        addEntry("Dana", "emptyReservoir", .critical, "Empty Reservoir", "Reservoir", .reservoirEmpty),
+        addEntry("Dana", "checkShaft", .critical, "Check Shaft", "Hardware", .hardwareFault),
+        addEntry("Dana", "basalMax", .active, "Basal Limit Reached", "Delivery", .insulinLimitWarning),
+        addEntry("Dana", "dailyMax", .active, "Daily Limit Reached", "Delivery", .insulinLimitWarning),
+        addEntry("Dana", "bloodSugarCheckMiss", .active, "Missed Blood Glucose Check", "Reminders", .userBloodGlucoseReminder),
+        addEntry("Dana", "ble5InvalidKeys", .timeSensitive, "Pairing Failed", "Connectivity", .pairingFailed),
+        addEntry("Dana", "unknown", .timeSensitive, "Unknown Pump Error", "Hardware", .hardwareFault)
+    ]
+}
+
+// MARK: - Medtrum (TouchCare nano)
+
+private extension AlertCatalogRegistry {
+    static let medtrumEntries: [Alert.CatalogEntry] = [
+        addEntry(
+            "Medtrum",
+            "com.nightscout.medtrumkit.patch-expired",
+            .active,
+            "Patch Expiring Soon",
+            "Lifecycle",
+            .deviceExpirationReminder
+        ),
+        addEntry(
+            "Medtrum",
+            "com.nightscout.medtrumkit.patch-daily-limit",
+            .timeSensitive,
+            "Daily Insulin Limit",
+            "Delivery",
+            .insulinLimitReached
+        ),
+        addEntry(
+            "Medtrum",
+            "com.nightscout.medtrumkit.patch-hourly-limit",
+            .timeSensitive,
+            "Hourly Insulin Limit",
+            "Delivery",
+            .insulinLimitReached
+        ),
+        // MedtrumKit currently emits the occlusion alert with a misspelled
+        // identifier ("patch-occlussion", double "s") — see MedtrumKit's
+        // NotificationManager. We register both the misspelled identifier (what
+        // ships today) and the corrected spelling so the alert escalates to
+        // critical regardless of which MedtrumKit is bundled. Drop the
+        // misspelled entry once the typo is fixed upstream.
+        addEntry("Medtrum", "com.nightscout.medtrumkit.patch-occlussion", .critical, "Occlusion", "Delivery", .occlusion),
+        addEntry("Medtrum", "com.nightscout.medtrumkit.patch-occlusion", .critical, "Occlusion", "Delivery", .occlusion),
+        addEntry("Medtrum", "com.nightscout.medtrumkit.patch-fault", .critical, "Patch Fault", "Hardware", .hardwareFault),
+        addEntry("Medtrum", "com.nightscout.medtrumkit.patch-empty", .critical, "Reservoir Empty", "Reservoir", .reservoirEmpty),
+        addEntry(
+            "Medtrum",
+            "com.nightscout.medtrumkit.reservoir-low",
+            .timeSensitive,
+            "Reservoir Low",
+            "Reservoir",
+            .reservoirLow
+        )
+    ]
+}
+
+// MARK: - Helpers
+
+private extension AlertCatalogRegistry {
+    static func addEntry(
+        _ manager: String,
+        _ alertID: String,
+        _ level: Alert.InterruptionLevel,
+        _ title: String,
+        _ category: String,
+        _ concept: Alert.CatalogConcept
+    ) -> Alert.CatalogEntry {
+        Alert.CatalogEntry(
+            managerIdentifier: manager,
+            alertIdentifier: alertID,
+            interruptionLevel: level,
+            title: title,
+            category: category,
+            concept: concept
+        )
+    }
+}

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

@@ -0,0 +1,111 @@
+import Foundation
+import LoopKit
+
+/// Plugin-side declaration of every alert a device manager may issue.
+/// Mirrors LoopKit's `AlertSoundVendor` pattern (static, boot-time, no
+/// side effects). Trio uses the union catalog to drive per-alert severity,
+/// the Settings UI, and per-alert mute. When upstream LoopKit adopts this,
+/// Trio's bridge extensions move into the plugin repos verbatim.
+protocol AlertCatalogVendor {
+    var alertCatalog: [Alert.CatalogEntry] { get }
+}
+
+extension Alert {
+    /// Cross-plugin canonical key for the Settings UI. Multiple per-plugin
+    /// entries with the same concept collapse to a single row.
+    enum CatalogConcept: Hashable {
+        case occlusion
+        case reservoirLow
+        case reservoirEmpty
+        case pumpBatteryLow
+        case pumpBatteryEmpty
+        case rileyLinkBatteryLow
+        case hardwareFault
+        case insulinLimitWarning
+        case insulinLimitReached
+        case suspendInProgressReminder
+        case insulinResumeReminder
+        case deviceExpirationReminder
+        case deviceExpired
+        case deviceShutdownImminent
+        case setupIncomplete
+        case timeChange
+        case pairingFailed
+        case userBloodGlucoseReminder
+        case basalProfileMismatch
+        /// Plugin-unique alarm that doesn't merge with anything else.
+        case unspecified
+
+        var displayTitle: String {
+            switch self {
+            case .occlusion: return String(localized: "Occlusion")
+            case .reservoirLow: return String(localized: "Reservoir Low")
+            case .reservoirEmpty: return String(localized: "Reservoir Empty")
+            case .pumpBatteryLow: return String(localized: "Pump Battery Low")
+            case .pumpBatteryEmpty: return String(localized: "Pump Battery Empty")
+            case .rileyLinkBatteryLow: return String(localized: "RileyLink Battery Low")
+            case .hardwareFault: return String(localized: "Pump Fault")
+            case .insulinLimitWarning: return String(localized: "Insulin Limit Warning")
+            case .insulinLimitReached: return String(localized: "Insulin Limit Reached")
+            case .suspendInProgressReminder: return String(localized: "Suspend In Progress")
+            case .insulinResumeReminder: return String(localized: "Insulin Resume Reminder")
+            case .deviceExpirationReminder: return String(localized: "Device Expiration Reminder")
+            case .deviceExpired: return String(localized: "Device Expired")
+            case .deviceShutdownImminent: return String(localized: "Device Shutdown Imminent")
+            case .setupIncomplete: return String(localized: "Setup Incomplete")
+            case .timeChange: return String(localized: "Time Change Detected")
+            case .pairingFailed: return String(localized: "Pairing Failed")
+            case .userBloodGlucoseReminder: return String(localized: "Blood Glucose Reminder")
+            case .basalProfileMismatch: return String(localized: "Basal Profile Mismatch")
+            case .unspecified: return ""
+            }
+        }
+    }
+
+    struct CatalogEntry: Identifiable, Equatable {
+        let identifier: Alert.Identifier
+        let interruptionLevel: Alert.InterruptionLevel
+        /// Plugin's authentic title — surfaces in logs / debug paths.
+        let title: String
+        /// Soft grouping ("Delivery", "Reservoir", "Hardware", ...).
+        let category: String
+        /// UI dedupe key — entries sharing a concept collapse to one row.
+        let concept: CatalogConcept
+
+        var id: String { identifier.value }
+
+        init(
+            identifier: Alert.Identifier,
+            interruptionLevel: Alert.InterruptionLevel,
+            title: String,
+            category: String,
+            concept: CatalogConcept
+        ) {
+            self.identifier = identifier
+            self.interruptionLevel = interruptionLevel
+            self.title = title
+            self.category = category
+            self.concept = concept
+        }
+
+        init(
+            managerIdentifier: String,
+            alertIdentifier: Alert.AlertIdentifier,
+            interruptionLevel: Alert.InterruptionLevel,
+            title: String,
+            category: String,
+            concept: CatalogConcept
+        ) {
+            self.init(
+                identifier: Alert.Identifier(
+                    managerIdentifier: managerIdentifier,
+                    alertIdentifier: alertIdentifier
+                ),
+                interruptionLevel: interruptionLevel,
+                title: title,
+                category: category,
+                concept: concept
+            )
+        }
+    }
+}

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

@@ -0,0 +1,43 @@
+import CGMBLEKit
+import G7SensorKit
+import LibreTransmitter
+import LoopKit
+
+/// Trio-side stand-in for LoopKit next-dev's `CGMManager.providesOwnGlucoseAlerts`.
+/// Collapses to `manager?.providesOwnGlucoseAlerts ?? false` once the fork bumps.
+enum CGMManagerAlertOwnership {
+    struct OwningApp {
+        let name: String
+        /// URL scheme registered by the companion app, if known. Schemes
+        /// taken from the corresponding manager UI: `dexcomg6://` from
+        /// CGMBLEKitUI's TransmitterSettingsViewController, `dexcomg7://`
+        /// from G7SensorKitUI's G7SettingsView, `xdripswift://` from
+        /// Trio's own CGMType.appURL. The manager protocol's `appURL`
+        /// returns nil on our forks, so this table is the source of truth.
+        let deepLink: URL?
+    }
+
+    static func providesOwnGlucoseAlerts(manager: CGMManager?, sourceType: CGMType) -> Bool {
+        owningApp(manager: manager, sourceType: sourceType) != nil
+    }
+
+    static func owningApp(manager: CGMManager?, sourceType: CGMType) -> OwningApp? {
+        // `.xdrip` runs without a CGMManager instance (App Group source),
+        // so check the source type first.
+        if sourceType == .xdrip {
+            return OwningApp(name: "xDrip4iOS", deepLink: URL(string: "xdripswift://"))
+        }
+        switch manager {
+        case is G5CGMManager:
+            return OwningApp(name: "Dexcom G5", deepLink: nil)
+        case is G6CGMManager:
+            return OwningApp(name: "Dexcom G6 / One", deepLink: URL(string: "dexcomg6://"))
+        case is G7CGMManager:
+            return OwningApp(name: "Dexcom G7 / One+", deepLink: URL(string: "dexcomg7://"))
+        case is LibreTransmitterManagerV3:
+            return OwningApp(name: "FreeStyle Libre", deepLink: nil)
+        default:
+            return nil
+        }
+    }
+}

+ 163 - 0
Trio/Sources/Services/Alerts/CriticalAlertAudioPlayer.swift

@@ -0,0 +1,163 @@
+import AudioToolbox
+import AVFoundation
+import MediaPlayer
+import os.log
+import UIKit
+
+@MainActor
+final class CriticalAlertAudioPlayer {
+    private let log = OSLog(subsystem: "org.nightscout.Trio", category: "CriticalAlertAudioPlayer")
+
+    private var player: AVAudioPlayer?
+    private var vibrationTimer: Timer?
+
+    private let vibrationInterval: TimeInterval = 2.0
+    private let boostedVolume: Float = 1.0
+    private let volumeBooster = SystemVolumeBooster()
+
+    var isPlaying: Bool { player?.isPlaying ?? false }
+
+    /// Start (or restart) looping playback of a bundled critical alert sound.
+    /// Loops until `stop()` is called from `retractAlert` /
+    /// `handleAcknowledgement` in `BaseTrioAlertManager` — modeled on the
+    /// LoopFollow / Jonas loop-failure alarm. `.playback` audio session gives
+    /// the app a privileged background state so the alarm continues even if
+    /// the user has the screen locked.
+    func play(soundNamed soundName: String = "critical.caf") {
+        stop()
+
+        startVibration()
+
+        let resource = (soundName as NSString).deletingPathExtension
+        let ext = (soundName as NSString).pathExtension.isEmpty ? "caf" : (soundName as NSString).pathExtension
+        let url = Bundle.main.url(forResource: resource, withExtension: ext, subdirectory: "Sounds")
+            ?? Bundle.main.url(forResource: "critical", withExtension: "caf", subdirectory: "Sounds")
+        guard let url else {
+            os_log(
+                "Neither %{public}@ nor critical.caf found in main bundle; audio fallback unavailable",
+                log: log,
+                type: .error,
+                soundName
+            )
+            return
+        }
+
+        do {
+            let session = AVAudioSession.sharedInstance()
+            // .playback ignores the silent switch and Focus modes. Must be
+            // mixable: iOS refuses to activate a non-mixable session from
+            // the background (AVAudioSessionErrorCodeCannotInterruptOthers,
+            // 560557684 "Session activation failed"). .duckOthers +
+            // .mixWithOthers ducks/mixes our alarm over other audio instead
+            // of failing outright. Mixable .playback still plays at full
+            // volume when nothing else is active and still bypasses the
+            // silent switch.
+            try session.setCategory(.playback, mode: .default, options: [.duckOthers, .mixWithOthers])
+            try session.setActive(true, options: [])
+            volumeBooster.boost(to: boostedVolume)
+
+            let p = try AVAudioPlayer(contentsOf: url)
+            p.numberOfLoops = -1
+            p.volume = 1.0
+            p.prepareToPlay()
+            guard p.play() else {
+                os_log("AVAudioPlayer.play() returned false for %{public}@", log: log, type: .error, url.lastPathComponent)
+                try? session.setActive(false, options: [.notifyOthersOnDeactivation])
+                return
+            }
+            player = p
+            os_log("Started critical-alert audio playback (duration=%{public}.2fs)", log: log, type: .info, p.duration)
+        } catch {
+            os_log("Failed to start audio playback: %{public}@", log: log, type: .error, String(describing: error))
+        }
+    }
+
+    private func startVibration() {
+        AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
+        vibrationTimer = Timer.scheduledTimer(withTimeInterval: vibrationInterval, repeats: true) { _ in
+            AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
+        }
+    }
+
+    /// Stop playback if any. Safe to call when not playing.
+    func stop() {
+        guard player != nil || vibrationTimer != nil else { return }
+        player?.stop()
+        player = nil
+        vibrationTimer?.invalidate()
+        vibrationTimer = nil
+        volumeBooster.restore()
+        try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
+        os_log("Stopped critical-alert audio playback", log: log, type: .info)
+    }
+}
+
+/// Best-effort override of the system output volume.
+///
+/// iOS has no public API to set the hardware volume. `MPVolumeView` hosts a
+/// `UISlider` that drives the system volume when its value changes — the
+/// long-standing workaround. Used ONLY for critical-alert audio on builds
+/// without the Critical Alerts entitlement: without it an urgent-low alarm
+/// can be silent when the ringer is down overnight.
+///
+/// Best-effort: the slider only populates once the hosting view is in an
+/// on-screen window, so it may no-op when the app is fully backgrounded. The
+/// alarm's audio and vibration run regardless — this only makes them louder
+/// when it can. Pre-boost level is restored on `restore()` unless the user
+/// manually changed the volume in the meantime.
+@MainActor private final class SystemVolumeBooster {
+    private let volumeView = MPVolumeView(frame: CGRect(x: -2000, y: -2000, width: 1, height: 1))
+    private var savedVolume: Float?
+    private var targetVolume: Float?
+    private var generation = 0
+
+    func boost(to target: Float) {
+        guard let window = Self.activeWindow() else { return }
+        attach(to: window)
+        generation &+= 1
+        let gen = generation
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
+            guard let self, gen == self.generation, let slider = self.slider() else { return }
+            let current = slider.value
+            guard current < target else { return }
+            self.savedVolume = current
+            self.targetVolume = target
+            slider.setValue(target, animated: false)
+        }
+    }
+
+    func restore() {
+        generation &+= 1
+        if let saved = savedVolume,
+           let target = targetVolume,
+           let slider = slider(),
+           abs(slider.value - target) < 0.05
+        {
+            slider.setValue(saved, animated: false)
+        }
+        savedVolume = nil
+        targetVolume = nil
+        volumeView.removeFromSuperview()
+    }
+
+    private func attach(to window: UIWindow) {
+        volumeView.alpha = 0.0001
+        volumeView.isUserInteractionEnabled = false
+        if volumeView.superview !== window {
+            volumeView.removeFromSuperview()
+            window.addSubview(volumeView)
+        }
+    }
+
+    private func slider() -> UISlider? {
+        volumeView.subviews.compactMap { $0 as? UISlider }.first
+    }
+
+    private static func activeWindow() -> UIWindow? {
+        let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
+        let active = scenes.first { $0.activationState == .foregroundActive }
+        return active?.windows.first(where: { $0.isKeyWindow })
+            ?? active?.windows.first
+            ?? scenes.flatMap(\.windows).first
+    }
+}

+ 178 - 0
Trio/Sources/Services/Alerts/DeviceAlertsStore.swift

@@ -0,0 +1,178 @@
+import Combine
+import Foundation
+import LoopKit
+
+/// Persists a flat list of `[DeviceAlertSeverityConfig]` to `UserDefaults`.
+/// Multiple configs per severity tier are allowed — each with its own
+/// `activeOption` so users can vary behavior between day and night.
+///
+/// Seeds three default configs (one per tier, all `activeOption: .always`)
+/// on first launch so every severity has a baseline that always matches.
+final class DeviceAlertsStore: ObservableObject {
+    static let shared = DeviceAlertsStore()
+
+    @Published var configs: [DeviceAlertSeverityConfig]
+    /// Per-tier snooze expirations keyed by `DeviceAlertSeverity.rawValue`.
+    @Published var tierSnoozes: [String: Date]
+
+    private let defaults: UserDefaults
+    private let configsKey: String
+    private let snoozesKey: String
+
+    private var subscriptions = Set<AnyCancellable>()
+
+    init(
+        defaults: UserDefaults = .standard,
+        configsKey: String = "trio.deviceAlertSeverityConfigs.v1",
+        snoozesKey: String = "trio.deviceAlertTierSnoozes.v1"
+    ) {
+        self.defaults = defaults
+        self.configsKey = configsKey
+        self.snoozesKey = snoozesKey
+        let loaded = Self.decode([DeviceAlertSeverityConfig].self, from: defaults, key: configsKey) ?? []
+        var seeded = loaded
+        for severity in DeviceAlertSeverity.allCases
+            where !seeded.contains(where: { $0.severity == severity && $0.activeOption == .always })
+        {
+            seeded.append(DeviceAlertSeverityConfig(severity: severity, activeOption: .always))
+        }
+        configs = Self.sorted(seeded)
+        let snoozes = Self.decode([String: Date].self, from: defaults, key: snoozesKey) ?? [:]
+        tierSnoozes = snoozes.filter { $0.value > Date() }
+        bind()
+    }
+
+    // MARK: - Per-tier snooze
+
+    func snoozeTier(_ tier: DeviceAlertSeverity, until: Date) {
+        if until > Date() {
+            tierSnoozes[tier.rawValue] = until
+        } else {
+            tierSnoozes.removeValue(forKey: tier.rawValue)
+        }
+    }
+
+    func isTierSnoozed(_ tier: DeviceAlertSeverity, at date: Date) -> Bool {
+        guard let until = tierSnoozes[tier.rawValue] else { return false }
+        return until > date
+    }
+
+    private func bind() {
+        $configs
+            .dropFirst()
+            .removeDuplicates()
+            .sink { [weak self] value in self?.encode(value, to: self?.configsKey ?? "") }
+            .store(in: &subscriptions)
+        $tierSnoozes
+            .dropFirst()
+            .removeDuplicates()
+            .sink { [weak self] value in self?.encode(value, to: self?.snoozesKey ?? "") }
+            .store(in: &subscriptions)
+    }
+
+    // MARK: - Lookup
+
+    /// Find the active config for a severity at the given moment. Considers
+    /// only enabled variants; picks the one whose `activeOption` matches the
+    /// current day/night window, falling back to the `.always` baseline.
+    /// Returns nil if every variant in this severity is disabled — caller
+    /// should drop the alarm in that case (user explicitly opted out).
+    func config(
+        for severity: DeviceAlertSeverity,
+        at _: Date,
+        isNight: Bool
+    ) -> DeviceAlertSeverityConfig? {
+        // Critical configs are always considered enabled — the editor hides
+        // the Enabled toggle on this tier, but legacy stored data may still
+        // carry `isEnabled = false` from a prior install. Honoring that
+        // flag here would silence the alarm despite the UI no longer
+        // exposing a way to re-enable it.
+        let matching = configs.filter { config in
+            guard config.severity == severity else { return false }
+            return severity == .critical || config.isEnabled
+        }
+        let windowMatch = matching.first { config in
+            switch config.activeOption {
+            case .always: return false // .always is the fallback, prefer specific match
+            case .day: return !isNight
+            case .night: return isNight
+            }
+        }
+        if let windowMatch { return windowMatch }
+        return matching.first { $0.activeOption == .always } ?? matching.first
+    }
+
+    /// All configs in a single severity tier, sorted by `activeOption`
+    /// (Day & Night, Day only, Night only).
+    func configs(in severity: DeviceAlertSeverity) -> [DeviceAlertSeverityConfig] {
+        configs.filter { $0.severity == severity }
+    }
+
+    // MARK: - Mutators
+
+    func add(_ config: DeviceAlertSeverityConfig) {
+        configs.append(config)
+        configs = Self.sorted(configs)
+    }
+
+    func update(_ config: DeviceAlertSeverityConfig) {
+        guard let index = configs.firstIndex(where: { $0.id == config.id }) else { return }
+        configs[index] = config
+        configs = Self.sorted(configs)
+    }
+
+    func remove(_ config: DeviceAlertSeverityConfig) {
+        guard canDelete(config) else { return }
+        configs.removeAll { $0.id == config.id }
+    }
+
+    /// At least one `.always` config per severity must remain so every alarm
+    /// has a baseline to fall back to. Other variants (.day / .night) can be
+    /// freely removed.
+    func canDelete(_ config: DeviceAlertSeverityConfig) -> Bool {
+        guard config.activeOption == .always else { return true }
+        let alwaysCount = configs.filter { $0.severity == config.severity && $0.activeOption == .always }.count
+        return alwaysCount > 1
+    }
+
+    // MARK: - Sorting + Codable helpers
+
+    private static func sorted(_ list: [DeviceAlertSeverityConfig]) -> [DeviceAlertSeverityConfig] {
+        list.sorted { lhs, rhs in
+            if lhs.severity != rhs.severity {
+                return severityRank(lhs.severity) < severityRank(rhs.severity)
+            }
+            return activeRank(lhs.activeOption) < activeRank(rhs.activeOption)
+        }
+    }
+
+    private static func severityRank(_ severity: DeviceAlertSeverity) -> Int {
+        switch severity {
+        case .critical: return 0
+        case .timeSensitive: return 1
+        case .normal: return 2
+        }
+    }
+
+    private static func activeRank(_ option: ActiveOption) -> Int {
+        switch option {
+        case .always: return 0
+        case .day: return 1
+        case .night: return 2
+        }
+    }
+
+    private static func decode<T: Decodable>(
+        _: T.Type,
+        from defaults: UserDefaults,
+        key: String
+    ) -> T? {
+        guard let data = defaults.data(forKey: key) else { return nil }
+        return try? JSONDecoder().decode(T.self, from: data)
+    }
+
+    private func encode<T: Encodable>(_ value: T, to key: String) {
+        guard !key.isEmpty, let data = try? JSONEncoder().encode(value) else { return }
+        defaults.set(data, forKey: key)
+    }
+}

+ 53 - 0
Trio/Sources/Services/Alerts/ForecastedGlucoseEvaluator.swift

@@ -0,0 +1,53 @@
+import Foundation
+
+/// Picks a predicted-glucose value at a fixed horizon across the available
+/// `Determination.predictions` curves (IOB / COB / UAM / ZT).
+///
+/// Mirrors the multi-curve selection in the oref-swift port
+/// (`ForecastGenerator.blendForecasts`) but at a single index instead of
+/// across the post-peak window. The Trio determination JSON already holds
+/// every curve at 5-min increments, so index 4 ≈ +20 min.
+///
+/// Reduction is `min` of available curves at the horizon — conservative for
+/// low detection. oref-swift's own blender uses `max` because it feeds SMB
+/// dosing safety; flipping to `min` for an alarm uses the same input set but
+/// with the inverted intent.
+enum ForecastedGlucoseEvaluator {
+    static let defaultHorizonMinutes = 20
+
+    enum Curve: String { case iob, cob, uam, zt }
+
+    struct Result: Equatable {
+        let predictedGlucose: Decimal
+        let horizonMinutes: Int
+        let curvesUsed: Set<Curve>
+        let perCurve: [Curve: Decimal]
+    }
+
+    static func evaluate(
+        determination: Determination,
+        horizonMinutes: Int = defaultHorizonMinutes
+    ) -> Result? {
+        let index = horizonMinutes / 5
+        guard let predictions = determination.predictions else { return nil }
+
+        var samples: [Curve: Decimal] = [:]
+        if let v = sample(predictions.iob, at: index) { samples[.iob] = v }
+        if let v = sample(predictions.cob, at: index) { samples[.cob] = v }
+        if let v = sample(predictions.uam, at: index) { samples[.uam] = v }
+        if let v = sample(predictions.zt, at: index) { samples[.zt] = v }
+        guard let minSample = samples.values.min() else { return nil }
+
+        return Result(
+            predictedGlucose: minSample,
+            horizonMinutes: horizonMinutes,
+            curvesUsed: Set(samples.keys),
+            perCurve: samples
+        )
+    }
+
+    private static func sample(_ array: [Int]?, at index: Int) -> Decimal? {
+        guard let array, array.indices.contains(index) else { return nil }
+        return Decimal(array[index])
+    }
+}

+ 473 - 0
Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift

@@ -0,0 +1,473 @@
+import Combine
+import CoreData
+import Foundation
+import LoopKit
+import Swinject
+
+/// Evaluates user-configured `GlucoseAlert`s on every new CGM reading and every
+/// new determination, then issues / retracts alerts via `TrioAlertManager`.
+/// Mirrors the LoopFollow alarm-list model — multiple alarms of the same type
+/// are allowed, each with its own threshold / schedule / sound.
+///
+/// Lookup flow:
+///   1. CGM update → fetch latest `GlucoseStored` + prior readings.
+///      For each enabled alarm matching the current day/night window:
+///        - urgentLow / low: latest ≤ threshold (+ optional persistence)
+///        - high: latest ≥ threshold (+ optional persistence)
+///   2. Determination update → blend forecast at the alarm's predictive horizon
+///      (forecastedLow: fixed 20 min) and compare to threshold.
+///
+/// Throttling + snooze are inherited from `TrioAlertManager.issueAlert`. The
+/// coordinator additionally tracks per-alarm firing state so it can retract
+/// the alert when the condition recovers (no flap-spam).
+final class GlucoseAlertCoordinator: Injectable {
+    @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var glucoseStorage: GlucoseStorage!
+    @Injected() private var trioAlertManager: TrioAlertManager!
+    @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var fetchGlucoseManager: FetchGlucoseManager!
+
+    private let coreDataContext = CoreDataStack.shared.newTaskContext()
+    private let evaluationQueue = DispatchQueue(label: "GlucoseAlertCoordinator.queue")
+    private var firingAlertIDs: Set<UUID> = []
+    private var subscriptions = Set<AnyCancellable>()
+    @SyncAccess private var alertsSnapshot: [GlucoseAlert] = []
+    @SyncAccess private var configurationSnapshot = GlucoseAlertConfiguration()
+
+    /// 5 mg/dL recovery margin before we retract a fired alert. Prevents flap
+    /// at the threshold boundary.
+    static let recoveryMarginMgDL: Decimal = 5
+
+    /// Pure breach predicate. Low family (low/urgentLow/forecastedLow) breaches
+    /// when the value is at or below threshold; high breaches at or above.
+    /// `carbsRequired` is determination-driven and evaluated separately.
+    /// Extracted for unit testing — the instance evaluators call through here.
+    static func breached(type: GlucoseAlertType, latestMgDL: Decimal, thresholdMgDL: Decimal) -> Bool {
+        switch type {
+        case .forecastedLow,
+             .low,
+             .urgentLow:
+            return latestMgDL <= thresholdMgDL
+        case .high:
+            return latestMgDL >= thresholdMgDL
+        case .carbsRequired:
+            return false
+        }
+    }
+
+    /// Pure retract predicate. A fired low-family alert retracts once the value
+    /// recovers to threshold + margin; a high alert once it falls to
+    /// threshold - margin. Extracted for unit testing.
+    static func shouldRetract(
+        type: GlucoseAlertType,
+        latestMgDL: Decimal,
+        thresholdMgDL: Decimal,
+        recoveryMarginMgDL: Decimal = recoveryMarginMgDL
+    ) -> Bool {
+        switch type {
+        case .forecastedLow,
+             .low,
+             .urgentLow:
+            return latestMgDL >= thresholdMgDL + recoveryMarginMgDL
+        case .high:
+            return latestMgDL <= thresholdMgDL - recoveryMarginMgDL
+        case .carbsRequired:
+            return false
+        }
+    }
+
+    /// Readings older than this are considered stale and won't drive new
+    /// alarms — matches `APSManager`'s loop-input freshness gate (12 min,
+    /// allowing for one missed CGM transmission on a 5-min schedule).
+    /// Without this gate, force-quitting the app and reopening it after a
+    /// CGM blackout could fire an alarm based on a 19-min-old reading.
+    private static let readingFreshnessWindow: TimeInterval = 12 * 60
+
+    /// Suppresses evaluations for a short window after launch. `firingAlertIDs`
+    /// isn't persisted across relaunches, so without this quiet window the
+    /// first reading after launch would re-fire any in-flight alarm whose
+    /// glucose is still past threshold. UN throttle dedupes the lock-screen
+    /// notification (5-min window), but the in-app banner can re-pop. Skipping
+    /// the first ~30s lets the picture settle.
+    private static let launchQuietWindow: TimeInterval = 30
+    private let launchedAt = Date()
+    private var isInLaunchQuietWindow: Bool {
+        Date().timeIntervalSince(launchedAt) < Self.launchQuietWindow
+    }
+
+    private var effectiveTrioAlertsEnabled: Bool {
+        if configurationSnapshot.forceTrioAlertsWhenCGMProvidesOwn { return true }
+        return !CGMManagerAlertOwnership.providesOwnGlucoseAlerts(
+            manager: fetchGlucoseManager?.cgmManager,
+            sourceType: fetchGlucoseManager?.cgmGlucoseSourceType ?? .none
+        )
+    }
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        let store = GlucoseAlertsStore.shared
+        alertsSnapshot = store.alerts
+        configurationSnapshot = store.configuration
+        store.$alerts
+            .sink { [weak self] new in self?.alertsSnapshot = new }
+            .store(in: &subscriptions)
+        store.$configuration
+            .sink { [weak self] new in self?.configurationSnapshot = new }
+            .store(in: &subscriptions)
+        broadcaster.register(DeterminationObserver.self, observer: self)
+        broadcaster.register(SnoozeObserver.self, observer: self)
+        broadcaster.register(GlucoseSnoozeObserver.self, observer: self)
+        glucoseStorage.updatePublisher
+            .sink { [weak self] _ in
+                Task { [weak self] in await self?.evaluateGlucoseAlarms() }
+            }
+            .store(in: &subscriptions)
+    }
+
+    // MARK: - Reading-based evaluation
+
+    /// Two-stage: async-fetch the latest value (Core Data perform), then hop
+    /// onto `evaluationQueue` to mutate `firingAlertIDs` safely.
+    private func evaluateGlucoseAlarms() async {
+        guard !isInLaunchQuietWindow else { return }
+        guard effectiveTrioAlertsEnabled else {
+            retractAllFiringIfNeeded()
+            return
+        }
+        guard let latestValue = await fetchLatestReadingMgDL() else { return }
+        let snapshot = alertsSnapshot
+        let configuration = configurationSnapshot
+        let now = Date()
+        evaluationQueue.async { [weak self] in
+            self?.applyReadingBasedEvaluation(
+                latestValue: latestValue,
+                snapshot: snapshot,
+                configuration: configuration,
+                now: now
+            )
+        }
+    }
+
+    private func applyReadingBasedEvaluation(
+        latestValue: Decimal,
+        snapshot: [GlucoseAlert],
+        configuration: GlucoseAlertConfiguration,
+        now: Date
+    ) {
+        dispatchPrecondition(condition: .onQueue(evaluationQueue))
+        // Iterate in type priority order so we can suppress lesser low-family
+        // alarms when a more severe one is firing (urgent low > low). Without
+        // this, glucose=60 with both urgent-low (54) AND low (70) configured
+        // would surface two in-app alerts for the same low event.
+        let sorted = snapshot.sorted { $0.type.priority < $1.type.priority }
+        var urgentLowFiring = false
+        for alarm in sorted where alarm.type.isReadingDriven {
+            if alarm.type == .low, urgentLowFiring {
+                retractIfFiring(alarm)
+                continue
+            }
+            evaluateReadingBased(alarm, latestMgDL: latestValue, now: now, configuration: configuration)
+            if alarm.type == .urgentLow, firingAlertIDs.contains(alarm.id) {
+                urgentLowFiring = true
+            }
+        }
+    }
+
+    private func evaluateReadingBased(
+        _ alarm: GlucoseAlert,
+        latestMgDL: Decimal,
+        now: Date,
+        configuration: GlucoseAlertConfiguration
+    ) {
+        guard alarm.shouldEvaluate, !isAlarmSnoozed(alarm, at: now),
+              isActive(alarm, at: now, configuration: configuration)
+        else {
+            retractIfFiring(alarm)
+            return
+        }
+
+        if alarm.type == .forecastedLow {
+            return // handled via determinationDidUpdate
+        }
+        let breached = Self.breached(
+            type: alarm.type,
+            latestMgDL: latestMgDL,
+            thresholdMgDL: alarm.thresholdMgDL
+        )
+
+        if breached {
+            fireIfNeeded(alarm, valueMgDL: latestMgDL)
+        } else if shouldRetract(alarm, latestMgDL: latestMgDL) {
+            retractIfFiring(alarm)
+        }
+    }
+
+    // MARK: - Forecast-based evaluation
+
+    private func evaluateForecast(_ determination: Determination) {
+        guard !isInLaunchQuietWindow else { return }
+        guard effectiveTrioAlertsEnabled else {
+            retractAllFiringIfNeeded()
+            return
+        }
+        let snapshot = alertsSnapshot
+        let configuration = configurationSnapshot
+        let now = Date()
+
+        // Suppress forecasted-low if any urgent-low or low alarm is already
+        // firing — the forecast came true (or worse), no point preempting
+        // it with a second alert.
+        let lowFamilyFiring = snapshot.contains { alarm in
+            (alarm.type == .urgentLow || alarm.type == .low)
+                && firingAlertIDs.contains(alarm.id)
+        }
+
+        for alarm in snapshot where alarm.type == .forecastedLow {
+            if lowFamilyFiring {
+                retractIfFiring(alarm)
+                continue
+            }
+            evaluateForecastBased(alarm, determination: determination, now: now, configuration: configuration)
+        }
+    }
+
+    private func evaluateForecastBased(
+        _ alarm: GlucoseAlert,
+        determination: Determination,
+        now: Date,
+        configuration: GlucoseAlertConfiguration
+    ) {
+        guard alarm.shouldEvaluate, !isAlarmSnoozed(alarm, at: now),
+              isActive(alarm, at: now, configuration: configuration),
+              let result = ForecastedGlucoseEvaluator.evaluate(determination: determination)
+        else {
+            retractIfFiring(alarm)
+            return
+        }
+
+        if result.predictedGlucose <= alarm.thresholdMgDL {
+            fireIfNeeded(alarm, valueMgDL: result.predictedGlucose)
+        } else if shouldRetract(alarm, latestMgDL: result.predictedGlucose) {
+            retractIfFiring(alarm)
+        }
+    }
+
+    // MARK: - Carbs-required evaluation
+
+    /// Mirrors `evaluateForecast` for the carbs-required alarms. The alarm
+    /// threshold (grams, stored in `thresholdMgDL`) is independent from the
+    /// tab badge's threshold — surfaces are configured separately so the
+    /// user can have a low-threshold badge for awareness and a higher-
+    /// threshold alarm for active interruption.
+    private func evaluateCarbsRequired(_ determination: Determination) {
+        guard !isInLaunchQuietWindow else { return }
+        let snapshot = alertsSnapshot
+        let configuration = configurationSnapshot
+        let now = Date()
+        let carbsReq: Decimal? = determination.carbsReq
+
+        for alarm in snapshot where alarm.type == .carbsRequired {
+            guard alarm.shouldEvaluate, !isAlarmSnoozed(alarm, at: now),
+                  isActive(alarm, at: now, configuration: configuration)
+            else {
+                retractIfFiring(alarm)
+                continue
+            }
+            guard let carbs = carbsReq, carbs >= alarm.thresholdMgDL else {
+                retractIfFiring(alarm)
+                continue
+            }
+            fireIfNeeded(alarm, valueMgDL: carbs)
+        }
+    }
+
+    // MARK: - Issue / retract bookkeeping
+
+    /// Called only from the evaluation queue (forecast + reading paths both
+    /// dispatch through `evaluationQueue` before invoking the evaluators),
+    /// so `firingAlertIDs` is serialized without an extra `.sync` hop.
+    private func fireIfNeeded(_ alarm: GlucoseAlert, valueMgDL: Decimal) {
+        dispatchPrecondition(condition: .onQueue(evaluationQueue))
+        guard !firingAlertIDs.contains(alarm.id) else { return }
+        firingAlertIDs.insert(alarm.id)
+
+        let title = alarm.name.isEmpty ? alarm.type.displayName : alarm.name
+        let body = bodyText(for: alarm, valueMgDL: valueMgDL)
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: String(localized: "OK")
+        )
+        let alert = Alert(
+            identifier: alertID(for: alarm),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: alarm.overridesSilenceAndDND ? .critical : .timeSensitive,
+            sound: alarm.playsSound ? .sound(name: alarm.soundFilename) : nil
+        )
+        trioAlertManager.issueAlert(alert)
+    }
+
+    private func retractIfFiring(_ alarm: GlucoseAlert) {
+        dispatchPrecondition(condition: .onQueue(evaluationQueue))
+        guard firingAlertIDs.contains(alarm.id) else { return }
+        firingAlertIDs.remove(alarm.id)
+        trioAlertManager.retractAlert(identifier: alertID(for: alarm))
+    }
+
+    private func retractAllFiringIfNeeded() {
+        evaluationQueue.async { [weak self] in
+            guard let self, !self.firingAlertIDs.isEmpty else { return }
+            let snapshot = self.alertsSnapshot
+            for alarm in snapshot where self.firingAlertIDs.contains(alarm.id) {
+                self.trioAlertManager.retractAlert(identifier: self.alertID(for: alarm))
+            }
+            self.firingAlertIDs.removeAll()
+        }
+    }
+
+    private func shouldRetract(_ alarm: GlucoseAlert, latestMgDL: Decimal) -> Bool {
+        dispatchPrecondition(condition: .onQueue(evaluationQueue))
+        guard firingAlertIDs.contains(alarm.id) else { return false }
+        return Self.shouldRetract(
+            type: alarm.type,
+            latestMgDL: latestMgDL,
+            thresholdMgDL: alarm.thresholdMgDL
+        )
+    }
+
+    private func alertID(for alarm: GlucoseAlert) -> Alert.Identifier {
+        let typeSlug: String
+        switch alarm.type {
+        case .urgentLow: typeSlug = "urgentLow"
+        case .low: typeSlug = "low"
+        case .forecastedLow: typeSlug = "forecastedLow"
+        case .high: typeSlug = "high"
+        case .carbsRequired: typeSlug = "carbsRequired"
+        }
+        return Alert.Identifier(
+            managerIdentifier: BaseTrioAlertManager.managerIdentifier,
+            alertIdentifier: "glucose.\(typeSlug).\(alarm.id.uuidString)"
+        )
+    }
+
+    private func bodyText(for alarm: GlucoseAlert, valueMgDL: Decimal) -> String {
+        let units = settingsManager.settings.units
+        let valueString = valueMgDL.formatted(withUnits: units)
+        let limitString = alarm.thresholdMgDL.formatted(withUnits: units)
+        switch alarm.type {
+        case .low,
+             .urgentLow:
+            return String(
+                format: String(localized: "Glucose %1$@."),
+                valueString, limitString
+            )
+        case .forecastedLow:
+            return String(
+                format: String(localized: "Your glucose may go below %2$@ in %1$d min."),
+                ForecastedGlucoseEvaluator.defaultHorizonMinutes, limitString
+            )
+        case .high:
+            return String(
+                format: String(localized: "Glucose %1$@."),
+                valueString, limitString
+            )
+        case .carbsRequired:
+            return String(
+                format: String(localized: "To prevent LOW required %d g of carbs"),
+                Int(NSDecimalNumber(decimal: valueMgDL).intValue)
+            )
+        }
+    }
+
+    // MARK: - Helpers
+
+    private func isAlarmSnoozed(_ alarm: GlucoseAlert, at date: Date) -> Bool {
+        guard let until = alarm.snoozedUntil else { return false }
+        return until > date
+    }
+
+    private func isActive(_ alarm: GlucoseAlert, at date: Date, configuration: GlucoseAlertConfiguration) -> Bool {
+        switch alarm.activeOption {
+        case .always: return true
+        case .day: return !configuration.isNight(at: date)
+        case .night: return configuration.isNight(at: date)
+        }
+    }
+
+    /// Async fetch matching Trio's standard Core Data pattern — never blocks
+    /// the caller's thread, never lets a `GlucoseStored` managed object cross
+    /// queue boundaries (would otherwise trip
+    /// `_PFAssertSafeMultiThreadedAccess_impl`).
+    private func fetchLatestReadingMgDL() async -> Decimal? {
+        let cutoff = Date().addingTimeInterval(-Self.readingFreshnessWindow)
+        let predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
+        do {
+            let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+                ofType: GlucoseStored.self,
+                onContext: coreDataContext,
+                predicate: predicate,
+                key: "date",
+                ascending: false,
+                fetchLimit: 1
+            )
+            return await coreDataContext.perform {
+                guard let latest = (results as? [GlucoseStored])?.first else { return nil }
+                return Decimal(latest.glucose)
+            }
+        } catch {
+            debug(.service, "GlucoseAlertCoordinator: glucose fetch failed: \(error)")
+            return nil
+        }
+    }
+}
+
+extension GlucoseAlertCoordinator: DeterminationObserver {
+    func determinationDidUpdate(_ determination: Determination) {
+        evaluationQueue.async { [weak self] in
+            self?.evaluateForecast(determination)
+            self?.evaluateCarbsRequired(determination)
+        }
+    }
+}
+
+extension GlucoseAlertCoordinator: SnoozeObserver {
+    /// Global snooze (Snooze module / lock-screen action). Clear all firing
+    /// IDs so the post-snooze evaluation can re-fire fresh if any condition
+    /// still breaches.
+    func snoozeDidChange(_ untilDate: Date) {
+        guard untilDate > Date() else { return }
+        evaluationQueue.async { [weak self] in
+            self?.firingAlertIDs.removeAll()
+        }
+    }
+}
+
+extension GlucoseAlertCoordinator: GlucoseSnoozeObserver {
+    /// Per-type snooze from an in-app banner tap / swipe / menu choice.
+    /// Stamps `snoozedUntil` on every matching `GlucoseAlert`, retracts every
+    /// in-flight alert of that type (so a stacked deck of multiple low
+    /// alarms dismisses as one unit), and drops matching entries from
+    /// `firingAlertIDs` so the next post-snooze reading evaluates fresh.
+    func snoozeGlucoseType(_ type: GlucoseAlertType, until untilDate: Date) {
+        DispatchQueue.main.async {
+            let store = GlucoseAlertsStore.shared
+            var updated = store.alerts
+            var matchingAlarms: [GlucoseAlert] = []
+            for index in updated.indices where updated[index].type == type {
+                updated[index].snoozedUntil = untilDate
+                matchingAlarms.append(updated[index])
+            }
+            guard !matchingAlarms.isEmpty else { return }
+            store.alerts = updated
+            for alarm in matchingAlarms {
+                self.trioAlertManager.retractAlert(identifier: self.alertID(for: alarm))
+            }
+            let ids = Set(matchingAlarms.map(\.id))
+            self.evaluationQueue.async { [weak self] in
+                self?.firingAlertIDs.subtract(ids)
+            }
+        }
+    }
+}

+ 145 - 0
Trio/Sources/Services/Alerts/GlucoseAlertsStore.swift

@@ -0,0 +1,145 @@
+import Combine
+import Foundation
+
+/// Persists the user's `[GlucoseAlert]` list and `GlucoseAlertConfiguration`
+/// to `UserDefaults`. SwiftUI views bind to `@Published` properties; mutations
+/// flow back to disk on the next runloop.
+final class GlucoseAlertsStore: ObservableObject {
+    static let shared = GlucoseAlertsStore()
+
+    @Published var alerts: [GlucoseAlert]
+    @Published var configuration: GlucoseAlertConfiguration
+
+    private let defaults: UserDefaults
+    private let alertsKey: String
+    private let configKey: String
+
+    private var subscriptions = Set<AnyCancellable>()
+
+    init(
+        defaults: UserDefaults = .standard,
+        alertsKey: String = "trio.glucoseAlerts.v1",
+        configKey: String = "trio.glucoseAlertConfiguration.v1"
+    ) {
+        self.defaults = defaults
+        self.alertsKey = alertsKey
+        self.configKey = configKey
+        let loaded = Self.decode([GlucoseAlert].self, from: defaults, key: alertsKey) ?? []
+        if loaded.isEmpty {
+            alerts = Self.defaultAlerts()
+        } else {
+            // Backfill alarm types added by later releases so upgrading users
+            // get a default-on entry instead of silently missing the type.
+            var migrated = loaded
+            let presentTypes = Set(loaded.map(\.type))
+            for type in GlucoseAlertType.allCases where !presentTypes.contains(type) {
+                migrated.append(GlucoseAlert(type: type))
+            }
+            alerts = migrated
+        }
+        configuration = Self.decode(
+            GlucoseAlertConfiguration.self,
+            from: defaults,
+            key: configKey
+        ) ?? GlucoseAlertConfiguration()
+        bind()
+    }
+
+    /// Seed every glucose alarm enabled. Users running a stock CGM app for
+    /// low/high notifications can disable the duplicates per-alarm; the
+    /// safer default is to have Trio alert until the user opts out.
+    /// `urgentLow` cannot be disabled from the editor regardless — it's the
+    /// safety floor — but the stored flag is kept honest so the UI binding
+    /// stays simple.
+    private static func defaultAlerts() -> [GlucoseAlert] {
+        [
+            GlucoseAlert(type: .urgentLow),
+            GlucoseAlert(type: .low),
+            GlucoseAlert(type: .forecastedLow),
+            GlucoseAlert(type: .high),
+            GlucoseAlert(type: .carbsRequired)
+        ]
+    }
+
+    private func bind() {
+        $alerts
+            .dropFirst()
+            .removeDuplicates()
+            .sink { [weak self] value in self?.encode(value, to: self?.alertsKey ?? "") }
+            .store(in: &subscriptions)
+        $configuration
+            .dropFirst()
+            .removeDuplicates()
+            .sink { [weak self] value in self?.encode(value, to: self?.configKey ?? "") }
+            .store(in: &subscriptions)
+    }
+
+    // MARK: - Mutators
+
+    func add(_ alert: GlucoseAlert) { alerts.append(alert) }
+
+    func update(_ alert: GlucoseAlert) {
+        guard let index = alerts.firstIndex(where: { $0.id == alert.id }) else { return }
+        alerts[index] = alert
+    }
+
+    func remove(_ alert: GlucoseAlert) {
+        guard canDelete(alert) else { return }
+        alerts.removeAll { $0.id == alert.id }
+    }
+
+    /// At least one alarm of each type must remain so the user always has
+    /// urgent-low / low / forecasted-low / high coverage available. They can
+    /// disable any alarm via its enabled toggle but not delete the last one.
+    func canDelete(_ alert: GlucoseAlert) -> Bool {
+        alerts.filter { $0.type == alert.type }.count > 1
+    }
+
+    /// `ActiveOption`s a new alarm of `type` could still occupy without
+    /// overlapping an existing alarm of the same type. `.always` covers both
+    /// windows, so it's removed as soon as either `.day` or `.night` is taken.
+    /// Returns the empty set when the type is fully covered (either `.always`
+    /// is already present, or both `.day` AND `.night` are present).
+    func availableActiveOptions(forNewAlarmOfType type: GlucoseAlertType) -> Set<ActiveOption> {
+        availableActiveOptions(forType: type, excludingAlertID: nil)
+    }
+
+    /// Variant used when editing an existing alarm — excludes the alarm being
+    /// edited from the "taken" set so its current window stays valid.
+    func availableActiveOptions(
+        forType type: GlucoseAlertType,
+        excludingAlertID excludedID: UUID?
+    ) -> Set<ActiveOption> {
+        let taken = Set(
+            alerts
+                .filter { $0.type == type && $0.id != excludedID }
+                .map(\.activeOption)
+        )
+        // `.always` covers both windows — fully blocks all additions.
+        if taken.contains(.always) { return [] }
+        // `.day` + `.night` together also cover everything.
+        if taken.contains(.day), taken.contains(.night) { return [] }
+        var available = Set(ActiveOption.allCases)
+        available.subtract(taken)
+        // Anything already taken (even just `.day` or `.night`) makes
+        // `.always` redundant — pull it out so it's not an option.
+        if !taken.isEmpty { available.remove(.always) }
+        return available
+    }
+
+    // MARK: - Codable helpers
+
+    private static func decode<T: Decodable>(
+        _: T.Type,
+        from defaults: UserDefaults,
+        key: String
+    ) -> T? {
+        guard let data = defaults.data(forKey: key) else { return nil }
+        return try? JSONDecoder().decode(T.self, from: data)
+    }
+
+    private func encode<T: Encodable>(_ value: T, to key: String) {
+        guard !key.isEmpty, let data = try? JSONEncoder().encode(value) else { return }
+        defaults.set(data, forKey: key)
+    }
+}

+ 86 - 0
Trio/Sources/Services/Alerts/NotLoopingMonitor.swift

@@ -0,0 +1,86 @@
+import Combine
+import Foundation
+import LoopKit
+import Swinject
+
+/// Issues a `.notLooping` alarm if no successful loop completes within the
+/// configured grace period (default 20 minutes). On every successful loop
+/// the pending alarm is retracted and a fresh delayed alarm is rescheduled
+/// — using `Alert.Trigger.delayed(interval:)` so it fires via UN even if
+/// the app is suspended.
+///
+/// Replaces the legacy `scheduleMissingLoopNotifiactions` direct-UN path
+/// in `BaseUserNotificationsManager`. The alert now flows through
+/// `TrioAlertManager` and inherits tier config from Pump Alarms
+/// (Critical tier by default).
+final class NotLoopingMonitor: Injectable {
+    @Injected() private var apsManager: APSManager!
+    @Injected() private var trioAlertManager: TrioAlertManager!
+    @Injected() private var settingsManager: SettingsManager!
+
+    /// Minutes of staleness before the alarm fires. Mirrors the legacy
+    /// `firstInterval` (20 min) — the second 40-min reminder is dropped;
+    /// retract-on-loop semantics make it redundant.
+    private static let gracePeriodMinutes: Int = 20
+
+    private static let alertID = Alert.Identifier(
+        managerIdentifier: "trio.aps",
+        alertIdentifier: "loop.notActive"
+    )
+
+    private var subscriptions = Set<AnyCancellable>()
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        subscribe(to: apsManager.lastLoopDateSubject.eraseToAnyPublisher())
+    }
+
+    /// Publisher-only seam for tests: assigns the alert manager directly and
+    /// subscribes to a supplied loop-date publisher, avoiding the need to stub
+    /// the full `APSManager` protocol.
+    init(loopDates: AnyPublisher<Date, Never>, trioAlertManager: TrioAlertManager) {
+        self.trioAlertManager = trioAlertManager
+        subscribe(to: loopDates)
+    }
+
+    private func subscribe(to loopDates: AnyPublisher<Date, Never>) {
+        loopDates
+            .sink { [weak self] _ in self?.rescheduleAlarm() }
+            .store(in: &subscriptions)
+    }
+
+    private func rescheduleAlarm() {
+        // Retract first — clears pending UN, modal timer, and throttler so the
+        // next issueAlert isn't blocked by 5-min duplicate suppression.
+        trioAlertManager.retractAlert(identifier: Self.alertID)
+
+        // Skip when Trio isn't expected to be auto-enacting: open loop, an
+        // active manual temp basal, or a suspended pump. In all three the
+        // user has implicitly told Trio "don't loop right now"; a "Not
+        // Looping" alarm would just be noise. nil injection (publisher-only
+        // test seam) passes so the existing tests still exercise the
+        // retract/reschedule plumbing.
+        guard settingsManager?.settings.closedLoop != false,
+              apsManager?.isManualTempBasal != true,
+              apsManager?.isSuspended != true
+        else { return }
+
+        let content = Alert.Content(
+            title: String(localized: "Trio Not Active"),
+            body: String(
+                format: String(localized: "Last loop was more than %d min ago"),
+                Self.gracePeriodMinutes
+            ),
+            acknowledgeActionButtonLabel: String(localized: "OK")
+        )
+        let alert = Alert(
+            identifier: Self.alertID,
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .delayed(interval: TimeInterval(Self.gracePeriodMinutes * 60)),
+            interruptionLevel: .critical,
+            sound: .sound(name: "honk.caf")
+        )
+        trioAlertManager.issueAlert(alert)
+    }
+}

+ 171 - 0
Trio/Sources/Services/Alerts/TrioAlertCategory.swift

@@ -0,0 +1,171 @@
+import Foundation
+import LoopKit
+
+/// Internal slug + severity producer for Trio-emitted alerts (APSManager,
+/// notLooping, glucose threshold). Pump/device alarms now flow through
+/// `AlertCatalogRegistry` and don't touch this enum.
+enum TrioAlertCategory: Equatable {
+    case occlusion
+    case reservoirLow
+    case reservoirEmpty
+    case batteryLow
+    case batteryEmpty
+    case hardwareFault
+    case deliveryUncertain
+    case deviceExpirationReminder
+    case deviceExpired
+    case podShutdownImminent
+    case suspendTimeExpired
+    case bolusFailed
+    case manualTempBasalActive
+    case notLooping
+    case sensorFailure
+    case glucoseUrgentLow
+    case glucoseLow
+    case glucoseForecastedLow
+    case glucoseHigh
+    case glucoseDataStale
+    case algorithmError
+    case commsTransient
+    case other(String)
+
+    /// Whether an inbound alert in this category surfaces *immediately*.
+    /// `false` means the category is dwell-suppressed by `APSManager`
+    /// (count + time threshold) before it reaches `TrioAlertManager`, and
+    /// also dropped at the manager boundary if it leaks through. Today
+    /// only `commsTransient` qualifies — connectivity blips usually
+    /// recover on their own.
+    var shouldFireImmediately: Bool {
+        switch self {
+        case .algorithmError,
+             .batteryEmpty,
+             .batteryLow,
+             .bolusFailed,
+             .deliveryUncertain,
+             .deviceExpirationReminder,
+             .deviceExpired,
+             .glucoseDataStale,
+             .glucoseForecastedLow,
+             .glucoseHigh,
+             .glucoseLow,
+             .glucoseUrgentLow,
+             .hardwareFault,
+             .manualTempBasalActive,
+             .notLooping,
+             .occlusion,
+             .other,
+             .podShutdownImminent,
+             .reservoirEmpty,
+             .reservoirLow,
+             .sensorFailure,
+             .suspendTimeExpired:
+            return true
+        case .commsTransient:
+            return false
+        }
+    }
+
+    /// Slug used when Trio constructs its own `Alert.Identifier`.
+    var alertIdentifier: String {
+        switch self {
+        case .occlusion: return "occlusion"
+        case .reservoirLow: return "reservoirLow"
+        case .reservoirEmpty: return "reservoirEmpty"
+        case .batteryLow: return "batteryLow"
+        case .batteryEmpty: return "batteryEmpty"
+        case .hardwareFault: return "hardwareFault"
+        case .deliveryUncertain: return "deliveryUncertain"
+        case .deviceExpirationReminder: return "deviceExpirationReminder"
+        case .deviceExpired: return "deviceExpired"
+        case .podShutdownImminent: return "podShutdownImminent"
+        case .suspendTimeExpired: return "suspendTimeExpired"
+        case .bolusFailed: return "bolusFailed"
+        case .manualTempBasalActive: return "manualTempBasalActive"
+        case .notLooping: return "notLooping"
+        case .sensorFailure: return "sensorFailure"
+        case .glucoseUrgentLow: return "glucoseUrgentLow"
+        case .glucoseLow: return "glucoseLow"
+        case .glucoseForecastedLow: return "glucoseForecastedLow"
+        case .glucoseHigh: return "glucoseHigh"
+        case .glucoseDataStale: return "glucoseDataStale"
+        case .algorithmError: return "algorithmError"
+        case .commsTransient: return "commsTransient"
+        case let .other(id): return id
+        }
+    }
+
+    var interruptionLevel: Alert.InterruptionLevel {
+        switch self {
+        case .batteryEmpty,
+             .deliveryUncertain,
+             .glucoseUrgentLow,
+             .hardwareFault,
+             .notLooping,
+             .occlusion,
+             .reservoirEmpty,
+             .sensorFailure:
+            return .critical
+        case .batteryLow,
+             .bolusFailed,
+             .deviceExpired,
+             .glucoseDataStale,
+             .glucoseForecastedLow,
+             .glucoseHigh,
+             .glucoseLow,
+             .manualTempBasalActive,
+             .podShutdownImminent,
+             .reservoirLow,
+             .suspendTimeExpired:
+            return .timeSensitive
+        case .algorithmError,
+             .commsTransient,
+             .deviceExpirationReminder,
+             .other:
+            return .active
+        }
+    }
+}
+
+enum TrioAlertClassifier {
+    /// Classify a Swift error caught at the `APSManager` boundary so dwell
+    /// suppression + alert emission have a stable bucket.
+    static func categorize(error: Error) -> TrioAlertCategory {
+        if let apsError = error as? APSError {
+            switch apsError {
+            case let .pumpError(inner):
+                return categorize(pumpError: inner)
+            case .invalidPumpState:
+                return .hardwareFault
+            case .glucoseError:
+                return .glucoseDataStale
+            case .apsError:
+                return .algorithmError
+            case .manualBasalTemp:
+                return .manualTempBasalActive
+            }
+        }
+        return categorize(pumpError: error)
+    }
+
+    private static func categorize(pumpError: Error) -> TrioAlertCategory {
+        let description = String(describing: pumpError).lowercased()
+        if description.contains("uncertaindelivery") || description.contains("unacknowledged")
+            || description.contains("bolus may have failed")
+        {
+            return .deliveryUncertain
+        }
+        if description.contains("occlusion") || description.contains("occluded") { return .occlusion }
+        if description.contains("reservoirempty") || description.contains("emptyreservoir") { return .reservoirEmpty }
+        if description.contains("lowreservoir") { return .reservoirLow }
+        if description.contains("fault") || description.contains("patchfault") { return .hardwareFault }
+        if description.contains("podexpired") || description.contains("sensorexpired") { return .deviceExpired }
+        if description.contains("sensorfailed") || description.contains("sensorstopped") { return .sensorFailure }
+        if description.contains("communication") || description.contains("comms") || description.contains("notconnected")
+            || description.contains("noresponse") || description.contains("timeout") || description.contains("rssi")
+        {
+            return .commsTransient
+        }
+        if description.contains("bolusfailed") { return .bolusFailed }
+        return .other(String(describing: pumpError))
+    }
+}

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

@@ -0,0 +1,476 @@
+import Combine
+import Foundation
+import LoopKit
+import Swinject
+import UserNotifications
+
+protocol TrioAlertManager: AnyObject {
+    func issueAlert(_ alert: Alert)
+    func retractAlert(identifier: Alert.Identifier)
+
+    func register(responder: AlertResponder, for managerIdentifier: String)
+    func register(soundVendor: AlertSoundVendor, for managerIdentifier: String)
+    func unregister(managerIdentifier: String)
+
+    func handleAcknowledgement(identifier: Alert.Identifier)
+    func handleNotificationResponse(_ response: UNNotificationResponse)
+    func acknowledgeAllOutstanding()
+    /// Canonical snooze entry point used by every snooze surface:
+    /// Snooze module, phone UN action, watch UN action, in-app banner.
+    /// Persists snoozeUntilDate, mutes AlertMuter, clears any pending
+    /// non-critical UNs, broadcasts `SnoozeObserver`.
+    @MainActor func applySnooze(for duration: TimeInterval) async
+    /// Removes pending + already-delivered non-critical user notifications
+    /// posted via the new alert pipeline. Used internally when a snooze
+    /// begins so previously-scheduled delayed alerts (e.g. not-looping)
+    /// don't fire during the snooze window. Critical UNs are left in
+    /// place — they pierce snooze by design.
+    func clearPendingNonCriticalNotifications()
+
+    var muter: AlertMuter { get }
+    var modalScheduler: TrioModalAlertScheduler { get }
+
+    func soundURL(for alert: Alert) -> URL?
+}
+
+final class BaseTrioAlertManager: TrioAlertManager, Injectable {
+    private struct WeakRef {
+        weak var ref: AnyObject?
+    }
+
+    static let managerIdentifier = "Trio"
+    static let soundsDirectoryName = "Sounds"
+
+    @Injected() private var alertHistoryStorage: AlertHistoryStorage!
+    @Injected() private var broadcaster: Broadcaster!
+
+    let muter: AlertMuter
+    private let throttler: AlertThrottler
+    private let soundLoader: AlertSoundLoader
+    /// Created lazily on first main-actor access. `CriticalAlertAudioPlayer`
+    /// is `@MainActor` (its `MPVolumeView` + `Timer` members require main),
+    /// but `BaseTrioAlertManager.init` runs on the Swinject resolve thread.
+    @MainActor private var criticalAudioPlayer: CriticalAlertAudioPlayer?
+
+    let modalScheduler: TrioModalAlertScheduler
+    private let userNotificationScheduler: TrioUserNotificationAlertScheduler
+
+    private let queue = DispatchQueue(label: "BaseTrioAlertManager.queue")
+    private var responders: [String: WeakRef] = [:]
+    private var soundVendors: [String: WeakRef] = [:]
+    private var liveAlerts: [Alert.Identifier: Alert] = [:]
+
+    init(resolver: Resolver) {
+        muter = AlertMuter()
+        throttler = AlertThrottler()
+        let soundsRoot = FileManager.default
+            .urls(for: .libraryDirectory, in: .userDomainMask)
+            .last!
+            .appendingPathComponent(Self.soundsDirectoryName, isDirectory: true)
+        soundLoader = AlertSoundLoader(destination: soundsRoot)
+        modalScheduler = TrioModalAlertScheduler()
+        userNotificationScheduler = TrioUserNotificationAlertScheduler(
+            notificationCenter: UNUserNotificationCenter.current(),
+            soundsRoot: soundsRoot
+        )
+        injectServices(resolver)
+        modalScheduler.responder = self
+        userNotificationScheduler.responder = self
+        // Rehydrate the in-memory mute window from the persisted snoozeUntilDate
+        // so a force-quit + relaunch during an active snooze still suppresses
+        // non-critical alerts until the originally-chosen end time.
+        let persistedSnoozeUntil = UserDefaults.standard
+            .object(forKey: "UserNotificationsManager.snoozeUntilDate") as? Date
+        if let until = persistedSnoozeUntil, until > Date() {
+            muter.mute(for: until.timeIntervalSinceNow)
+        }
+    }
+
+    /// Falls back to in-process AVAudioPlayer for `.critical` alerts on
+    /// builds without the Critical Alerts entitlement. iOS silently
+    /// downgrades `.criticalSoundNamed` and `.defaultCritical` to a regular
+    /// notification without the entitlement, which DnD / silent switch /
+    /// Focus modes then mute — dangerous for an overnight urgent-low.
+    /// `.playback` audio session bypasses those.
+    ///
+    /// Only fires for immediate-trigger alerts; delayed/repeating go
+    /// through UNNotification at fire time. No-op when muted or non-critical.
+    private func playCriticalAudioFallbackIfNeeded(_ alert: Alert, muted: Bool) {
+        guard alert.interruptionLevel == .critical, !muted else { return }
+        guard case .immediate = alert.trigger else { return }
+        // Honor `playsSound: false` (alert was issued with sound: nil) —
+        // user explicitly opted out of audio on this alarm.
+        guard let soundName = alert.sound?.filename else { return }
+        Task { @MainActor in
+            if criticalAudioPlayer == nil { criticalAudioPlayer = CriticalAlertAudioPlayer() }
+            criticalAudioPlayer?.play(soundNamed: soundName)
+        }
+    }
+
+    // MARK: - Issue / Retract
+
+    func issueAlert(_ alert: Alert) {
+        debug(
+            .service,
+            "TrioAlertManager.issueAlert \(alert.identifier.value) level=\(alert.interruptionLevel)"
+        )
+
+        // Pump alerts: look up in catalog → override interruptionLevel.
+        // Everything else (CGM lifecycle, Trio-internal glucose / loop) is
+        // passed through with the level its producer chose.
+        let effective: Alert = AlertCatalogRegistry.lookup(alert.identifier).map { entry in
+            applyCatalogEntry(entry, to: alert)
+        } ?? alert
+
+        // Per-tier snooze for catalog-known pump alerts. Critical tier
+        // ignores snooze.
+        if let tier = DeviceAlertSeverity(level: effective.interruptionLevel),
+           AlertCatalogRegistry.lookup(effective.identifier) != nil,
+           tier != .critical,
+           DeviceAlertsStore.shared.isTierSnoozed(tier, at: Date())
+        {
+            debug(.service, "TrioAlertManager dropped \(effective.identifier.value): tier \(tier) snoozed")
+            return
+        }
+
+        let now = Date()
+        // Critical alerts pierce the snooze/mute window. Everything else is
+        // suppressed entirely while muted (no modal, no UN sound, no critical
+        // audio fallback).
+        if effective.interruptionLevel != .critical, muter.shouldMute(at: now) {
+            debug(.service, "TrioAlertManager muted \(effective.identifier.value) (snooze window active)")
+            return
+        }
+        guard throttler.shouldDeliver(effective) else {
+            debug(.service, "TrioAlertManager throttled \(effective.identifier.value)")
+            return
+        }
+        queue.async {
+            self.liveAlerts[effective.identifier] = effective
+        }
+        recordIssued(effective)
+        let muted = muter.shouldMute(at: now)
+        modalScheduler.schedule(effective)
+        userNotificationScheduler.schedule(
+            effective,
+            muted: muted,
+            soundURL: soundLoader.url(for: effective)
+        )
+        playCriticalAudioFallbackIfNeeded(effective, muted: muted)
+    }
+
+    private func applyCatalogEntry(_ entry: Alert.CatalogEntry, to alert: Alert) -> Alert {
+        Alert(
+            identifier: alert.identifier,
+            foregroundContent: alert.foregroundContent,
+            backgroundContent: alert.backgroundContent,
+            trigger: alert.trigger,
+            interruptionLevel: entry.interruptionLevel,
+            sound: alert.sound,
+            metadata: alert.metadata
+        )
+    }
+
+    func retractAlert(identifier: Alert.Identifier) {
+        queue.async {
+            self.liveAlerts.removeValue(forKey: identifier)
+        }
+        modalScheduler.unschedule(identifier: identifier)
+        userNotificationScheduler.unschedule(identifier: identifier)
+        throttler.reset(identifier: identifier)
+        Task { @MainActor in criticalAudioPlayer?.stop() }
+        alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
+    }
+
+    // MARK: - Acknowledgement
+
+    func handleAcknowledgement(identifier: Alert.Identifier) {
+        modalScheduler.unschedule(identifier: identifier)
+        userNotificationScheduler.unschedule(identifier: identifier)
+        Task { @MainActor in criticalAudioPlayer?.stop() }
+        alertHistoryStorage.acknowledgeAlert(issuedDate(for: identifier) ?? Date(), nil)
+        queue.async {
+            self.liveAlerts.removeValue(forKey: identifier)
+        }
+        let responder = queue.sync { responders[identifier.managerIdentifier]?.ref as? AlertResponder }
+        responder?.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) { error in
+            if let error = error {
+                debug(.service, "AlertManager ack failed for \(identifier.value): \(error)")
+            }
+        }
+    }
+
+    func handleNotificationResponse(_ response: UNNotificationResponse) {
+        let userInfo = response.notification.request.content.userInfo
+        guard
+            let managerId = userInfo[AlertUserInfoKey.managerIdentifier.rawValue] as? String,
+            let alertId = userInfo[AlertUserInfoKey.alertIdentifier.rawValue] as? String
+        else { return }
+        let identifier = Alert.Identifier(managerIdentifier: managerId, alertIdentifier: alertId)
+        handleAcknowledgement(identifier: identifier)
+    }
+
+    func acknowledgeAllOutstanding() {
+        let outstanding = alertHistoryStorage.unacknowledgedAlertsWithinLast24Hours()
+        for entry in outstanding {
+            let identifier = Alert.Identifier(
+                managerIdentifier: entry.managerIdentifier,
+                alertIdentifier: entry.alertIdentifier
+            )
+            handleAcknowledgement(identifier: identifier)
+        }
+    }
+
+    func clearPendingNonCriticalNotifications() {
+        let center = UNUserNotificationCenter.current()
+        center.getPendingNotificationRequests { requests in
+            let ids = requests
+                .filter { $0.content.interruptionLevel != .critical }
+                .map(\.identifier)
+            guard !ids.isEmpty else { return }
+            center.removePendingNotificationRequests(withIdentifiers: ids)
+        }
+        center.getDeliveredNotifications { delivered in
+            let ids = delivered
+                .filter { $0.request.content.interruptionLevel != .critical }
+                .map(\.request.identifier)
+            guard !ids.isEmpty else { return }
+            center.removeDeliveredNotifications(withIdentifiers: ids)
+        }
+    }
+
+    /// Persisted under the same key UN reads, so a force-quit + relaunch
+    /// during a snooze sees the same end-date.
+    private static let snoozeUntilDateKey = "UserNotificationsManager.snoozeUntilDate"
+    private static let legacyGlucoseNotificationID = "Trio.glucoseNotification"
+
+    @MainActor func applySnooze(for duration: TimeInterval) async {
+        let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
+        UserDefaults.standard.set(untilDate, forKey: Self.snoozeUntilDateKey)
+
+        // Legacy glucose-notification UN cleanup (the new pipeline uses
+        // per-alarm identifiers; this catches anything still lingering
+        // from older installs).
+        let center = UNUserNotificationCenter.current()
+        center.removeDeliveredNotifications(withIdentifiers: [Self.legacyGlucoseNotificationID])
+        center.removePendingNotificationRequests(withIdentifiers: [Self.legacyGlucoseNotificationID])
+
+        if duration > 0 {
+            muter.mute(for: duration)
+            clearPendingNonCriticalNotifications()
+            modalScheduler.clearNonCriticalBanners()
+        } else {
+            muter.unmute()
+        }
+
+        broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in
+            observer.snoozeDidChange(untilDate)
+        }
+    }
+
+    // MARK: - Registry
+
+    func register(responder: AlertResponder, for managerIdentifier: String) {
+        queue.async {
+            self.responders[managerIdentifier] = WeakRef(ref: responder as AnyObject)
+        }
+    }
+
+    func register(soundVendor: AlertSoundVendor, for managerIdentifier: String) {
+        queue.async {
+            self.soundVendors[managerIdentifier] = WeakRef(ref: soundVendor as AnyObject)
+        }
+        soundLoader.copySounds(from: soundVendor, managerIdentifier: managerIdentifier)
+    }
+
+    func unregister(managerIdentifier: String) {
+        queue.async {
+            self.responders.removeValue(forKey: managerIdentifier)
+            self.soundVendors.removeValue(forKey: managerIdentifier)
+        }
+    }
+
+    func soundURL(for alert: Alert) -> URL? {
+        soundLoader.url(for: alert)
+    }
+
+    // MARK: - Persistence bridge
+
+    private func recordIssued(_ alert: Alert) {
+        let entry = AlertEntry(
+            alertIdentifier: alert.identifier.alertIdentifier,
+            primitiveInterruptionLevel: alert.interruptionLevel.storedValue as? Decimal,
+            issuedDate: Date(),
+            managerIdentifier: alert.identifier.managerIdentifier,
+            triggerType: alert.trigger.storedType,
+            triggerInterval: alert.trigger.storedInterval as? Decimal,
+            contentTitle: alert.foregroundContent?.title ?? alert.backgroundContent.title,
+            contentBody: alert.foregroundContent?.body ?? alert.backgroundContent.body
+        )
+        alertHistoryStorage.addAlert(entry)
+    }
+
+    private func issuedDate(for identifier: Alert.Identifier) -> Date? {
+        alertHistoryStorage.unacknowledgedAlertsWithinLast24Hours()
+            .first {
+                $0.managerIdentifier == identifier.managerIdentifier
+                    && $0.alertIdentifier == identifier.alertIdentifier
+            }?.issuedDate
+    }
+}
+
+extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAlertResponder {
+    func requestSnooze(identifier: Alert.Identifier, duration: TimeInterval) {
+        let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
+        if let glucoseType = GlucoseAlertType(slug: identifier.alertIdentifier) {
+            broadcaster.notify(GlucoseSnoozeObserver.self, on: .main) { (observer: GlucoseSnoozeObserver) in
+                observer.snoozeGlucoseType(glucoseType, until: untilDate)
+            }
+        } else if let entry = AlertCatalogRegistry.lookup(identifier),
+                  let tier = DeviceAlertSeverity(level: entry.interruptionLevel),
+                  tier != .critical
+        {
+            DeviceAlertsStore.shared.snoozeTier(tier, until: untilDate)
+            retractAlertsInTier(tier)
+        } else {
+            Task { @MainActor [weak self] in
+                await self?.applySnooze(for: duration)
+            }
+        }
+    }
+
+    private func retractAlertsInTier(_ tier: DeviceAlertSeverity) {
+        let toRetract: [Alert.Identifier] = queue.sync {
+            liveAlerts.compactMap { id, alert in
+                guard let entry = AlertCatalogRegistry.lookup(id),
+                      DeviceAlertSeverity(level: entry.interruptionLevel) == tier
+                else { return nil }
+                _ = alert
+                return id
+            }
+        }
+        for id in toRetract {
+            retractAlert(identifier: id)
+        }
+    }
+
+    /// Convenience for the UN-action / Watch / Snooze-module paths that
+    /// don't have an originating alert identifier — they all want global.
+    func requestSnooze(duration: TimeInterval) {
+        Task { @MainActor [weak self] in
+            await self?.applySnooze(for: duration)
+        }
+    }
+
+    func isAlertActive(identifier: Alert.Identifier) -> Bool {
+        queue.sync { liveAlerts[identifier] != nil }
+    }
+
+    func isSnoozeActive(at date: Date) -> Bool {
+        muter.shouldMute(at: date)
+    }
+}
+
+/// Observer for per-type glucose snoozes triggered from an in-app banner.
+/// `GlucoseAlertCoordinator` registers and applies the snooze to its
+/// `snoozedUntil` field on every matching `GlucoseAlert`.
+protocol GlucoseSnoozeObserver {
+    func snoozeGlucoseType(_ type: GlucoseAlertType, until: Date)
+}
+
+enum AlertUserInfoKey: String {
+    case managerIdentifier = "trio.alert.managerIdentifier"
+    case alertIdentifier = "trio.alert.alertIdentifier"
+}
+
+// MARK: - Muter
+
+final class AlertMuter: ObservableObject {
+    @Published private(set) var startDate: Date?
+    @Published private(set) var duration: TimeInterval = 0
+
+    static let allowedDurations: [TimeInterval] = [
+        30 * 60,
+        60 * 60,
+        2 * 60 * 60,
+        4 * 60 * 60
+    ]
+
+    func mute(for duration: TimeInterval, from start: Date = Date()) {
+        startDate = start
+        self.duration = duration
+    }
+
+    func unmute() {
+        startDate = nil
+        duration = 0
+    }
+
+    func shouldMute(at date: Date) -> Bool {
+        guard let start = startDate else { return false }
+        return date >= start && date < start.addingTimeInterval(duration)
+    }
+
+    var endsAt: Date? {
+        guard let start = startDate else { return nil }
+        return start.addingTimeInterval(duration)
+    }
+}
+
+// MARK: - Throttler
+
+final class AlertThrottler {
+    private let queue = DispatchQueue(label: "AlertThrottler.queue")
+    private var lastDelivered: [Alert.Identifier: Date] = [:]
+    private let minimumInterval: TimeInterval = 5 * 60
+
+    func shouldDeliver(_ alert: Alert) -> Bool {
+        queue.sync {
+            let now = Date()
+            if let last = lastDelivered[alert.identifier], now.timeIntervalSince(last) < minimumInterval {
+                return false
+            }
+            lastDelivered[alert.identifier] = now
+            return true
+        }
+    }
+
+    func reset(identifier: Alert.Identifier) {
+        queue.sync { _ = lastDelivered.removeValue(forKey: identifier) }
+    }
+}
+
+// MARK: - Sound loader
+
+final class AlertSoundLoader {
+    private let destination: URL
+    private let fileManager: FileManager
+
+    init(destination: URL, fileManager: FileManager = .default) {
+        self.destination = destination
+        self.fileManager = fileManager
+        try? fileManager.createDirectory(at: destination, withIntermediateDirectories: true)
+    }
+
+    func copySounds(from vendor: AlertSoundVendor, managerIdentifier: String) {
+        guard let sourceBase = vendor.getSoundBaseURL() else { return }
+        let target = destination.appendingPathComponent(managerIdentifier, isDirectory: true)
+        try? fileManager.createDirectory(at: target, withIntermediateDirectories: true)
+        for sound in vendor.getSounds() {
+            guard let filename = sound.filename else { continue }
+            let source = sourceBase.appendingPathComponent(filename)
+            let dest = target.appendingPathComponent(filename)
+            guard fileManager.fileExists(atPath: source.path) else { continue }
+            if fileManager.fileExists(atPath: dest.path) { continue }
+            try? fileManager.copyItem(at: source, to: dest)
+        }
+    }
+
+    func url(for alert: Alert) -> URL? {
+        guard let filename = alert.sound?.filename else { return nil }
+        let url = destination
+            .appendingPathComponent(alert.identifier.managerIdentifier, isDirectory: true)
+            .appendingPathComponent(filename)
+        return fileManager.fileExists(atPath: url.path) ? url : nil
+    }
+}

+ 449 - 0
Trio/Sources/Services/Alerts/TrioModalAlertScheduler.swift

@@ -0,0 +1,449 @@
+import Combine
+import Foundation
+import LoopKit
+import SwiftUI
+
+protocol TrioModalAlertResponder: AnyObject {
+    func handleAcknowledgement(identifier: LoopKit.Alert.Identifier)
+    /// Snooze the alert (and any siblings of its type). Glucose alarms route
+    /// to per-type suppression — snoozing a "low" silences all low alarms
+    /// for `duration` but leaves urgent-low / high / forecasted-low firing
+    /// normally. Pump / device alerts fall back to the global muter.
+    func requestSnooze(identifier: LoopKit.Alert.Identifier, duration: TimeInterval)
+    /// Returns true if the alert is still tracked by the manager. Used by
+    /// `.delayed` timers to skip stale fires after the app resumes from
+    /// suspension — the timer fires immediately on resume, and without this
+    /// gate a banner would appear for an already-retracted alert.
+    func isAlertActive(identifier: LoopKit.Alert.Identifier) -> Bool
+    /// True if a global snooze is active. Non-critical `.delayed`/`.repeating`
+    /// banners check this at fire time so a previously-scheduled banner
+    /// (e.g. Not-Looping at +20m) stays silent during the snooze window.
+    func isSnoozeActive(at date: Date) -> Bool
+}
+
+final class TrioModalAlertScheduler: ObservableObject {
+    weak var responder: TrioModalAlertResponder?
+
+    @Published private(set) var active: [LoopKit.Alert] = []
+    private var pending: [LoopKit.Alert.Identifier: Timer] = [:]
+
+    func schedule(_ alert: LoopKit.Alert) {
+        DispatchQueue.main.async { [weak self] in self?.scheduleOnMain(alert) }
+    }
+
+    func unschedule(identifier: LoopKit.Alert.Identifier) {
+        DispatchQueue.main.async { [weak self] in self?.unscheduleOnMain(identifier: identifier) }
+    }
+
+    func acknowledge(identifier: LoopKit.Alert.Identifier) {
+        guard Thread.isMainThread else {
+            DispatchQueue.main.async { [weak self] in self?.acknowledge(identifier: identifier) }
+            return
+        }
+        responder?.handleAcknowledgement(identifier: identifier)
+        remove(identifier: identifier)
+    }
+
+    /// Drops every active non-critical banner. Called from `applySnooze` so
+    /// banners visible at the moment a global snooze starts disappear too —
+    /// otherwise they'd outlive the alarm they advertised.
+    func clearNonCriticalBanners() {
+        guard Thread.isMainThread else {
+            DispatchQueue.main.async { [weak self] in self?.clearNonCriticalBanners() }
+            return
+        }
+        active.removeAll { $0.interruptionLevel != .critical }
+    }
+
+    func snooze(identifier: LoopKit.Alert.Identifier, duration: TimeInterval) {
+        guard Thread.isMainThread else {
+            DispatchQueue.main.async { [weak self] in
+                self?.snooze(identifier: identifier, duration: duration)
+            }
+            return
+        }
+        responder?.requestSnooze(identifier: identifier, duration: duration)
+        // Drop the in-app banner too — the global snooze mutes the queue,
+        // and leaving this specific banner hanging would be noise.
+        remove(identifier: identifier)
+    }
+
+    /// "Snooze all" entry point from the expanded stack header. Each alert
+    /// goes through its own routing — glucose per-type, device per-category,
+    /// everything else global — so a mixed stack snoozes correctly bucket by
+    /// bucket.
+    func snoozeAll(duration: TimeInterval) {
+        guard Thread.isMainThread else {
+            DispatchQueue.main.async { [weak self] in self?.snoozeAll(duration: duration) }
+            return
+        }
+        let identifiers = active.map(\.identifier)
+        for identifier in identifiers {
+            responder?.requestSnooze(identifier: identifier, duration: duration)
+        }
+        active.removeAll()
+    }
+
+    private func scheduleOnMain(_ alert: LoopKit.Alert) {
+        guard alert.foregroundContent != nil else { return }
+        switch alert.trigger {
+        case .immediate:
+            insert(alert)
+        case let .delayed(interval):
+            scheduleTimer(alert: alert, interval: interval, repeats: false)
+        case let .repeating(interval):
+            scheduleTimer(alert: alert, interval: interval, repeats: true)
+        }
+    }
+
+    private func unscheduleOnMain(identifier: LoopKit.Alert.Identifier) {
+        pending.removeValue(forKey: identifier)?.invalidate()
+        remove(identifier: identifier)
+    }
+
+    /// Outcome of a `.delayed`/`.repeating` timer fire, factored out of the
+    /// timer closure so the snooze-pierce gate is unit-testable as a pure
+    /// function. See `shouldInsertOnFire`.
+    enum FireDecision: Equatable {
+        case insert
+        case suppressKeepPending
+        case dropStale
+    }
+
+    /// Pure decision for what a timer fire should do, given the alert's
+    /// interruption level and the manager's current active/snooze state.
+    /// - `dropStale`: alert no longer tracked — skip and drop pending.
+    /// - `suppressKeepPending`: non-critical banner muted by global snooze.
+    /// - `insert`: present the banner.
+    static func shouldInsertOnFire(
+        interruptionLevel: LoopKit.Alert.InterruptionLevel,
+        isAlertActive: Bool,
+        isSnoozeActive: Bool
+    ) -> FireDecision {
+        guard isAlertActive else { return .dropStale }
+        if interruptionLevel != .critical, isSnoozeActive { return .suppressKeepPending }
+        return .insert
+    }
+
+    private func scheduleTimer(alert: LoopKit.Alert, interval: TimeInterval, repeats: Bool) {
+        if pending[alert.identifier] != nil { return }
+        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: repeats) { [weak self] _ in
+            DispatchQueue.main.async {
+                guard let self else { return }
+                // Skip stale fires (e.g. iOS suspended the app for longer
+                // than `interval`; on resume the timer fires immediately
+                // even if the underlying alert was already retracted).
+                let isAlertActive = self.responder?.isAlertActive(identifier: alert.identifier) ?? true
+                // Honor active global snooze for non-critical banners.
+                let isSnoozeActive = self.responder?.isSnoozeActive(at: Date()) == true
+                switch Self.shouldInsertOnFire(
+                    interruptionLevel: alert.interruptionLevel,
+                    isAlertActive: isAlertActive,
+                    isSnoozeActive: isSnoozeActive
+                ) {
+                case .dropStale:
+                    self.pending.removeValue(forKey: alert.identifier)
+                case .suppressKeepPending:
+                    if !repeats {
+                        self.pending.removeValue(forKey: alert.identifier)
+                    }
+                case .insert:
+                    self.insert(alert)
+                    if !repeats {
+                        self.pending.removeValue(forKey: alert.identifier)
+                    }
+                }
+            }
+        }
+        pending[alert.identifier] = timer
+    }
+
+    private func insert(_ alert: LoopKit.Alert) {
+        guard !active.contains(where: { $0.identifier == alert.identifier }) else { return }
+        active.append(alert)
+        sortByPriority()
+    }
+
+    private func remove(identifier: LoopKit.Alert.Identifier) {
+        active.removeAll { $0.identifier == identifier }
+    }
+
+    private func sortByPriority() {
+        active.sort { Self.rank(of: $0.interruptionLevel) < Self.rank(of: $1.interruptionLevel) }
+    }
+
+    /// Lower rank = higher severity. Matches `DeviceAlertsStore.severityRank`
+    /// so the sort convention is consistent across the alert pipeline.
+    private static func rank(of level: LoopKit.Alert.InterruptionLevel) -> Int {
+        switch level {
+        case .critical: return 0
+        case .timeSensitive: return 1
+        case .active: return 2
+        }
+    }
+}
+
+struct TrioAlertBanner: View {
+    let alert: LoopKit.Alert
+    /// Single-tap action — `nil` means tap behaves like swipe-up (fires the
+    /// 15-min snooze). The collapsed stack overrides this with "expand",
+    /// since tapping a stacked card should reveal the rest of the deck
+    /// before letting the user dismiss anything individually.
+    var onTap: (() -> Void)? = nil
+    /// Snooze path — fires from swipe-up + long-press menu + (when `onTap`
+    /// is nil) tap.
+    let onSnooze: (TimeInterval) -> Void
+
+    @State private var presentedAt = Date()
+    @State private var dragOffset: CGSize = .zero
+
+    private static let quickSnooze: TimeInterval = 15 * 60
+
+    /// Critical alerts and urgent-low glucose alarms are limited to the
+    /// 15-minute quick snooze — the safety floor. Other alerts get the full
+    /// 15m / 1h / 3h / 6h menu.
+    private var isQuickSnoozeOnly: Bool {
+        if alert.interruptionLevel == .critical { return true }
+        if GlucoseAlertType(slug: alert.identifier.alertIdentifier) == .urgentLow { return true }
+        return false
+    }
+
+    private var content: LoopKit.Alert.Content? { alert.foregroundContent }
+
+    private var symbolName: String {
+        switch alert.interruptionLevel {
+        case .critical: return "exclamationmark.octagon.fill"
+        case .timeSensitive: return "exclamationmark.triangle.fill"
+        case .active: return "info.circle.fill"
+        }
+    }
+
+    private var accent: Color {
+        switch alert.interruptionLevel {
+        case .critical: return .red
+        case .timeSensitive: return .orange
+        case .active: return .blue
+        }
+    }
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 12) {
+            Image(systemName: symbolName)
+                .font(.system(size: 20, weight: .semibold))
+                .foregroundStyle(.white)
+                .frame(width: 38, height: 38)
+                .background(accent, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+
+            VStack(alignment: .leading, spacing: 2) {
+                HStack(alignment: .firstTextBaseline) {
+                    if let title = content?.title, !title.isEmpty {
+                        Text(title)
+                            .font(.subheadline.weight(.semibold))
+                    }
+                    Spacer(minLength: 8)
+                    TimelineView(.periodic(from: Date(), by: 5)) { context in
+                        Text(relativeTimestamp(now: context.date))
+                            .font(.footnote)
+                            .foregroundStyle(.secondary)
+                    }
+                }
+                if let body = content?.body, !body.isEmpty {
+                    Text(body)
+                        .font(.subheadline)
+                        .fixedSize(horizontal: false, vertical: true)
+                }
+            }
+
+            Menu {
+                ForEach(snoozeOptions, id: \.self) { action in
+                    Button {
+                        onSnooze(action.duration)
+                    } label: {
+                        Label(action.localizedTitle, systemImage: "moon.zzz")
+                    }
+                }
+            } label: {
+                Image(systemName: "moon.zzz.fill")
+                    .font(.title3)
+                    .foregroundStyle(.secondary)
+                    .frame(width: 32, height: 32)
+                    .contentShape(Rectangle())
+            }
+            .accessibilityLabel(Text("Snooze"))
+        }
+        .padding(12)
+        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
+        .shadow(color: .black.opacity(0.18), radius: 12, y: 4)
+        .contentShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+        .offset(y: min(0, dragOffset.height))
+        .opacity(1 - min(abs(dragOffset.height) / CGFloat(200), 0.4))
+        .gesture(
+            // Swipe-up — iOS-banner gesture; tracks the drag visually then
+            // commits a 15-minute snooze past −50pt. Springs back otherwise.
+            DragGesture(minimumDistance: 8)
+                .onChanged { value in
+                    guard value.translation.height < 0 else { return }
+                    dragOffset = value.translation
+                }
+                .onEnded { value in
+                    if value.translation.height < -50 {
+                        onSnooze(Self.quickSnooze)
+                    } else {
+                        withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+                            dragOffset = .zero
+                        }
+                    }
+                }
+        )
+        .onTapGesture {
+            if let onTap { onTap() } else { onSnooze(Self.quickSnooze) }
+        }
+        .contextMenu {
+            if isQuickSnoozeOnly {
+                // Critical + urgent-low: a single labeled action; no header,
+                // no other options.
+                Button {
+                    onSnooze(Self.quickSnooze)
+                } label: {
+                    Label(String(localized: "Snooze (15 min)"), systemImage: "moon.zzz")
+                }
+            } else {
+                Section(String(localized: "Snooze")) {
+                    ForEach(snoozeOptions, id: \.self) { action in
+                        Button {
+                            onSnooze(action.duration)
+                        } label: {
+                            Label(action.localizedTitle, systemImage: "moon.zzz")
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private var snoozeOptions: [NotificationResponseAction] {
+        isQuickSnoozeOnly ? [.snooze15] : NotificationResponseAction.allCases
+    }
+
+    private func relativeTimestamp(now: Date) -> String {
+        let elapsed = now.timeIntervalSince(presentedAt)
+        if elapsed < 5 {
+            return String(localized: "now")
+        } else if elapsed < 60 {
+            return String(format: String(localized: "%ds ago"), Int(elapsed))
+        } else if elapsed < 3600 {
+            return String(format: String(localized: "%dm ago"), Int(elapsed / 60))
+        } else {
+            return String(format: String(localized: "%dh ago"), Int(elapsed / 3600))
+        }
+    }
+}
+
+struct TrioAlertModifier: ViewModifier {
+    @ObservedObject var scheduler: TrioModalAlertScheduler
+    @State private var isExpanded = false
+
+    private static let maxStackedVisible = 3
+
+    func body(content: Content) -> some View {
+        ZStack(alignment: .top) {
+            content
+            if !scheduler.active.isEmpty {
+                bannerStack
+                    .padding(.horizontal, 16)
+                    .padding(.top, 8)
+                    .zIndex(1000)
+            }
+        }
+        .onChange(of: scheduler.active.count) { _, newCount in
+            if newCount <= 1 { isExpanded = false }
+        }
+        .animation(.spring(response: 0.45, dampingFraction: 0.85), value: scheduler.active.map(\.identifier.value))
+        .animation(.spring(response: 0.35, dampingFraction: 0.85), value: isExpanded)
+    }
+
+    @ViewBuilder private var bannerStack: some View {
+        if isExpanded || scheduler.active.count <= 1 {
+            VStack(spacing: 8) {
+                if scheduler.active.count > 1 {
+                    snoozeAllHeader
+                }
+                ForEach(scheduler.active, id: \.identifier) { alert in
+                    TrioAlertBanner(
+                        alert: alert,
+                        onSnooze: { duration in
+                            scheduler.snooze(identifier: alert.identifier, duration: duration)
+                        }
+                    )
+                    .transition(.move(edge: .top).combined(with: .opacity))
+                }
+            }
+        } else {
+            collapsedStack
+        }
+    }
+
+    /// iOS-style "clear all" affordance at the top of the expanded stack.
+    /// Runs every active alert through its own snooze routing — glucose
+    /// per-type, device per-category, etc.
+    private var snoozeAllHeader: some View {
+        HStack {
+            Spacer()
+            Button {
+                scheduler.snoozeAll(duration: 15 * 60)
+            } label: {
+                HStack(spacing: 4) {
+                    Text(String(localized: "Snooze all (15 min)"))
+                        .font(.footnote.weight(.semibold))
+                    Image(systemName: "moon.zzz.fill")
+                        .font(.footnote)
+                }
+                .foregroundStyle(.secondary)
+                .padding(.horizontal, 12)
+                .padding(.vertical, 6)
+                .background(.ultraThinMaterial, in: Capsule())
+            }
+            .buttonStyle(.plain)
+        }
+    }
+
+    private var collapsedStack: some View {
+        let visible = scheduler.active.prefix(Self.maxStackedVisible)
+        return ZStack(alignment: .top) {
+            ForEach(Array(visible.enumerated().reversed()), id: \.element.identifier) { index, alert in
+                TrioAlertBanner(
+                    alert: alert,
+                    onTap: {
+                        // Tap on the collapsed deck expands it; individual
+                        // banners then own their own tap-to-snooze.
+                        withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
+                            isExpanded = true
+                        }
+                    },
+                    onSnooze: { duration in
+                        scheduler.snooze(identifier: alert.identifier, duration: duration)
+                    }
+                )
+                .scaleEffect(1 - CGFloat(index) * 0.04, anchor: .top)
+                .offset(y: CGFloat(index) * 14)
+                .opacity(1 - Double(index) * 0.25)
+                .allowsHitTesting(index == 0)
+                .zIndex(Double(Self.maxStackedVisible - index))
+            }
+        }
+    }
+}
+
+extension View {
+    func trioAlerts(_ scheduler: TrioModalAlertScheduler) -> some View {
+        modifier(TrioAlertModifier(scheduler: scheduler))
+    }
+}
+
+#if DEBUG
+    extension TrioModalAlertScheduler {
+        /// Test-only seam: seeds the published `active` queue so tests can exercise
+        /// `clearNonCriticalBanners()` without driving the private `insert` path.
+        func seedForTesting(_ alerts: [LoopKit.Alert]) { active = alerts }
+    }
+#endif

+ 100 - 0
Trio/Sources/Services/Alerts/TrioUserNotificationAlertScheduler.swift

@@ -0,0 +1,100 @@
+import Foundation
+import LoopKit
+import UserNotifications
+
+protocol TrioUserNotificationAlertResponder: AnyObject {
+    func handleAcknowledgement(identifier: Alert.Identifier)
+}
+
+final class TrioUserNotificationAlertScheduler {
+    weak var responder: TrioUserNotificationAlertResponder?
+
+    private let notificationCenter: UNUserNotificationCenter
+    private let soundsRoot: URL
+
+    init(notificationCenter: UNUserNotificationCenter, soundsRoot: URL) {
+        self.notificationCenter = notificationCenter
+        self.soundsRoot = soundsRoot
+    }
+
+    func schedule(_ alert: Alert, muted: Bool, soundURL: URL?) {
+        let request = makeRequest(alert: alert, muted: muted, soundURL: soundURL)
+        notificationCenter.add(request) { error in
+            if let error = error {
+                debug(.service, "UserNotificationAlertScheduler failed: \(error.localizedDescription)")
+            }
+        }
+    }
+
+    func unschedule(identifier: Alert.Identifier) {
+        notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.value])
+        notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value])
+    }
+
+    private func makeRequest(alert: Alert, muted: Bool, soundURL: URL?) -> UNNotificationRequest {
+        let content = UNMutableNotificationContent()
+        content.title = alert.backgroundContent.title
+        content.body = alert.backgroundContent.body
+        content.threadIdentifier = alert.identifier.managerIdentifier
+        content.userInfo = [
+            AlertUserInfoKey.managerIdentifier.rawValue: alert.identifier.managerIdentifier,
+            AlertUserInfoKey.alertIdentifier.rawValue: alert.identifier.alertIdentifier
+        ]
+        content.interruptionLevel = alert.interruptionLevel.unNotificationLevel
+        content.sound = sound(for: alert, muted: muted, soundURL: soundURL)
+        // Surface the four quick-snooze actions (15 min / 1 h / 3 h / 6 h)
+        // on both phone and watch lock-screen notifications. The category +
+        // its actions are registered by `NotificationCategoryFactory` on
+        // both the phone (`BaseUserNotificationsManager`) and watch
+        // (`WatchNotificationHandler`) UN delegates.
+        content.categoryIdentifier = NotificationCategoryIdentifier.trioAlert.rawValue
+
+        return UNNotificationRequest(
+            identifier: alert.identifier.value,
+            content: content,
+            trigger: alert.trigger.unTrigger
+        )
+    }
+
+    private func sound(for alert: Alert, muted: Bool, soundURL: URL?) -> UNNotificationSound? {
+        let isCritical = alert.interruptionLevel == .critical
+        if muted {
+            return isCritical ? .defaultCriticalSound(withAudioVolume: 0) : nil
+        }
+        switch alert.sound {
+        case .none,
+             .vibrate:
+            return isCritical ? .defaultCriticalSound(withAudioVolume: 0) : nil
+        case let .sound(name):
+            if let filename = soundURL?.lastPathComponent {
+                let unName = UNNotificationSoundName(rawValue: filename)
+                return isCritical ? .criticalSoundNamed(unName) : UNNotificationSound(named: unName)
+            }
+            let unName = UNNotificationSoundName(name)
+            return isCritical ? .criticalSoundNamed(unName) : UNNotificationSound(named: unName)
+        }
+    }
+}
+
+private extension Alert.InterruptionLevel {
+    var unNotificationLevel: UNNotificationInterruptionLevel {
+        switch self {
+        case .active: return .active
+        case .timeSensitive: return .timeSensitive
+        case .critical: return .critical
+        }
+    }
+}
+
+private extension Alert.Trigger {
+    var unTrigger: UNNotificationTrigger? {
+        switch self {
+        case .immediate:
+            return nil
+        case let .delayed(interval):
+            return UNTimeIntervalNotificationTrigger(timeInterval: max(interval, 1), repeats: false)
+        case let .repeating(interval):
+            return UNTimeIntervalNotificationTrigger(timeInterval: max(interval, 60), repeats: true)
+        }
+    }
+}

+ 2 - 2
Trio/Sources/Services/ContactImage/ContactImageManager.swift

@@ -245,8 +245,8 @@ final class BaseContactImageManager: NSObject, ContactImageManager, Injectable {
             let hardCodedLow = Decimal(55)
             let hardCodedHigh = Decimal(220)
             let isDynamicColorScheme = settingsManager.settings.glucoseColorScheme == .dynamicColor
-            let highGlucoseColorValue = isDynamicColorScheme ? hardCodedHigh : settingsManager.settings.highGlucose
-            let lowGlucoseColorValue = isDynamicColorScheme ? hardCodedLow : settingsManager.settings.lowGlucose
+            let highGlucoseColorValue = isDynamicColorScheme ? hardCodedHigh : settingsManager.settings.high
+            let lowGlucoseColorValue = isDynamicColorScheme ? hardCodedLow : settingsManager.settings.low
             let fetchedTarget = await getCurrentGlucoseTarget() // ⚠️ this value is mg/dL
 
             state.highGlucoseColorValue = units == .mgdL ? highGlucoseColorValue : highGlucoseColorValue.asMmolL

+ 1 - 9
Trio/Sources/Services/Storage/FileStorage.swift

@@ -245,19 +245,11 @@ extension FileStorage {
         if var settings = retrieve(OpenAPS.Settings.settings, as: TrioSettings.self) {
             let initialHigh = settings.high
             let initialLow = settings.low
-            let initialHighGlucose = settings.highGlucose
-            let initialLowGlucose = settings.lowGlucose
 
             settings.high = parseSettingIfMmolL(value: settings.high)
             settings.low = parseSettingIfMmolL(value: settings.low)
-            settings.highGlucose = parseSettingIfMmolL(value: settings.highGlucose)
-            settings.lowGlucose = parseSettingIfMmolL(value: settings.lowGlucose)
 
-            if settings.high != initialHigh ||
-                settings.low != initialLow ||
-                settings.highGlucose != initialHighGlucose ||
-                settings.lowGlucose != initialLowGlucose
-            {
+            if settings.high != initialHigh || settings.low != initialLow {
                 debug(.businessLogic, "TrioSettings found in mmol/L. Parsing to mg/dL.")
                 save(settings, as: OpenAPS.Settings.settings)
                 wasParsed = true

+ 54 - 409
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -1,11 +1,9 @@
-import AudioToolbox
 import Combine
 import CoreData
 import Foundation
 import LoopKit
 import SwiftUI
 import Swinject
-import UIKit
 import UserNotifications
 
 protocol UserNotificationsManager {
@@ -14,12 +12,6 @@ protocol UserNotificationsManager {
     @MainActor func applySnooze(for duration: TimeInterval) async
 }
 
-enum GlucoseSourceKey: String {
-    case transmitterBattery
-    case nightscoutPing
-    case description
-}
-
 enum NotificationAction: String {
     static let key = "action"
 
@@ -28,19 +20,10 @@ enum NotificationAction: String {
     case none
 }
 
-protocol BolusFailureObserver {
-    func bolusDidFail()
-}
-
 protocol alertMessageNotificationObserver {
     func alertMessageNotification(_ message: MessageContent)
 }
 
-protocol pumpNotificationObserver {
-    func pumpNotification(alert: AlertEntry)
-    func pumpRemoveNotification()
-}
-
 // MARK: - SnoozeObserver Protocol
 
 protocol SnoozeObserver {
@@ -51,10 +34,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     enum Identifier: String {
         case glucoseNotification = "Trio.glucoseNotification"
         case carbsRequiredNotification = "Trio.carbsRequiredNotification"
-        case noLoopFirstNotification = "Trio.noLoopFirstNotification"
-        case noLoopSecondNotification = "Trio.noLoopSecondNotification"
-        case bolusFailedNotification = "Trio.bolusFailedNotification"
-        case pumpNotification = "Trio.pumpNotification"
         case alertMessageNotification = "Trio.alertMessageNotification"
     }
 
@@ -64,16 +43,11 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var apsManager: APSManager!
     @Injected() private var router: Router!
-
-    @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
+    @Injected() private var trioAlertManager: TrioAlertManager!
 
     @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
-    // The glucose notification observers below (Core Data saves and the storage publisher) can fire for the same
-    // reading, so we persist the last alert token to avoid enqueueing identical high/low notifications multiple times.
-    @Persisted(key: "UserNotificationsManager.lastGlucoseAlertToken") private var lastGlucoseAlertToken: String = ""
 
     private let notificationCenter = UNUserNotificationCenter.current()
-    private var lifetime = Lifetime()
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     private let backgroundContext = CoreDataStack.shared.newTaskContext()
@@ -83,9 +57,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
     private var subscriptions = Set<AnyCancellable>()
 
-    let firstInterval = 20 // min
-    let secondInterval = 40 // min
-
     init(resolver: Resolver) {
         super.init()
         notificationCenter.delegate = self
@@ -97,18 +68,11 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 .share()
                 .eraseToAnyPublisher()
 
-        broadcaster.register(DeterminationObserver.self, observer: self)
-        broadcaster.register(BolusFailureObserver.self, observer: self)
-        broadcaster.register(pumpNotificationObserver.self, observer: self)
         broadcaster.register(alertMessageNotificationObserver.self, observer: self)
-//        requestNotificationPermissionsIfNeeded()
-        Task {
-            await sendGlucoseNotification()
-        }
+        Task { await updateGlucoseBadge() }
         configureNotificationCategories()
-        registerHandlers()
-        registerSubscribers()
-        subscribeOnLoop()
+        clearLegacyCarbsRequiredNotification()
+        subscribeGlucoseUpdates()
     }
 
     private func configureNotificationCategories() {
@@ -127,33 +91,19 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
-    private func subscribeOnLoop() {
-        apsManager.lastLoopDateSubject
-            .sink { [weak self] date in
-                self?.scheduleMissingLoopNotifiactions(date: date)
-            }
-            .store(in: &lifetime)
-    }
-
-    private func registerHandlers() {
-        // Due to the Batch insert this only is used for observing Deletion of Glucose entries
-        coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task {
-                await self.sendGlucoseNotification()
-            }
-        }.store(in: &subscriptions)
-    }
-
-    private func registerSubscribers() {
+    /// Subscribes to the two sources that signal a glucose change so the app
+    /// icon badge stays current:
+    /// - `coreDataPublisher` filtered to `GlucoseStored` — catches deletions
+    ///   (batch inserts don't fire normal Core Data save notifications, so
+    ///   inserts come through `updatePublisher` below).
+    /// - `glucoseStorage.updatePublisher` — fires on every new reading.
+    private func subscribeGlucoseUpdates() {
+        coreDataPublisher?.filteredByEntityName("GlucoseStored")
+            .sink { [weak self] _ in Task { await self?.updateGlucoseBadge() } }
+            .store(in: &subscriptions)
         glucoseStorage.updatePublisher
             .receive(on: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task {
-                    await self.sendGlucoseNotification()
-                }
-            }
+            .sink { [weak self] _ in Task { await self?.updateGlucoseBadge() } }
             .store(in: &subscriptions)
     }
 
@@ -187,88 +137,12 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
-    private func notifyCarbsRequired(_ carbs: Int) {
-        guard Decimal(carbs) >= settingsManager.settings.carbsRequiredThreshold,
-              settingsManager.settings.showCarbsRequiredBadge, settingsManager.settings.notificationsCarb else { return }
-
-        var titles: [String] = []
-
-        let content = UNMutableNotificationContent()
-
-        if snoozeUntilDate > Date() {
-            return
-        }
-        content.sound = .default
-
-        titles.append(String(format: String(localized: "Carbs required: %d g", comment: "Carbs required"), carbs))
-
-        content.title = titles.joined(separator: " ")
-        content.body = String(
-            format: String(
-                localized:
-                "To prevent LOW required %d g of carbs",
-                comment: "To prevent LOW required %d g of carbs"
-            ),
-            carbs
-        )
-        addRequest(identifier: .carbsRequiredNotification, content: content, deleteOld: true, messageSubtype: .carb)
-    }
-
-    private func scheduleMissingLoopNotifiactions(date _: Date) {
-        let title = String(localized: "Trio Not Active", comment: "Trio Not Active")
-        let body = String(localized: "Last loop was more than %d min ago", comment: "Last loop was more than %d min ago")
-
-        let firstContent = UNMutableNotificationContent()
-        firstContent.title = title
-        firstContent.body = String(format: body, firstInterval)
-        firstContent.sound = .default
-
-        let secondContent = UNMutableNotificationContent()
-        secondContent.title = title
-        secondContent.body = String(format: body, secondInterval)
-        secondContent.sound = .default
-
-        let firstTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(firstInterval), repeats: false)
-        let secondTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(secondInterval), repeats: false)
-
-        addRequest(
-            identifier: .noLoopFirstNotification,
-            content: firstContent,
-            deleteOld: true,
-            trigger: firstTrigger,
-            messageType: .error,
-            messageSubtype: .algorithm
-        )
-        addRequest(
-            identifier: .noLoopSecondNotification,
-            content: secondContent,
-            deleteOld: true,
-            trigger: secondTrigger,
-            messageType: .error,
-            messageSubtype: .algorithm
-        )
-    }
-
-    private func notifyBolusFailure() {
-        let title = String(localized: "Bolus failed", comment: "Bolus failed")
-        let body = String(
-            localized:
-            "Bolus failed or inaccurate. Check pump history before repeating.",
-            comment: "Bolus failed or inaccurate. Check pump history before repeating."
-        )
-        let content = UNMutableNotificationContent()
-        content.title = title
-        content.body = body
-        content.sound = .default
-
-        addRequest(
-            identifier: .noLoopFirstNotification,
-            content: content,
-            deleteOld: true,
-            trigger: nil,
-            messageType: .error,
-            messageSubtype: .pump
-        )
+    /// Removes any `Trio.carbsRequiredNotification` UN still sitting in the
+    /// system from a pre-pipeline install. Safe no-op when none exist.
+    private func clearLegacyCarbsRequiredNotification() {
+        let id = Identifier.carbsRequiredNotification.rawValue
+        notificationCenter.removePendingNotificationRequests(withIdentifiers: [id])
+        notificationCenter.removeDeliveredNotifications(withIdentifiers: [id])
     }
 
     private func fetchGlucoseIDs() async throws -> [NSManagedObjectID] {
@@ -290,164 +164,23 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
-    @MainActor private func sendGlucoseNotification() async {
+    /// Refreshes the Trio app icon badge from the latest stored glucose
+    /// reading. Glucose alarm emission has moved to `GlucoseAlertCoordinator`
+    /// (urgent-low / low / forecasted-low / high are issued via
+    /// `TrioAlertManager` based on the user-configured `[GlucoseAlert]` list).
+    @MainActor private func updateGlucoseBadge() async {
         do {
             addAppBadge(glucose: nil)
             let glucoseIDs = try await fetchGlucoseIDs()
-            let glucoseObjects = try glucoseIDs.compactMap { id in
+            let latest = try glucoseIDs.compactMap { id in
                 try viewContext.existingObject(with: id) as? GlucoseStored
-            }
-
-            if glucoseStorage.alarm == .none {
-                lastGlucoseAlertToken = ""
-            }
-
-            guard let lastReading = glucoseObjects.first?.glucose,
-                  let secondLastReading = glucoseObjects.dropFirst().first?.glucose,
-                  let lastDirection = glucoseObjects.first?.directionEnum?.symbol else { return }
-
-            addAppBadge(glucose: (glucoseObjects.first?.glucose).map { Int($0) })
-
-            var titles: [String] = []
-            var notificationAlarm = false
-            var messageType = MessageType.info
-
-            switch glucoseStorage.alarm {
-            case .none:
-                titles.append(String(localized: "Glucose", comment: "Glucose"))
-            case .low:
-                titles.append(String(localized: "LOWALERT!", comment: "LOWALERT!"))
-                messageType = MessageType.warning
-                notificationAlarm = true
-            case .high:
-                titles.append(String(localized: "HIGHALERT!", comment: "HIGHALERT!"))
-                messageType = MessageType.warning
-                notificationAlarm = true
-            }
-
-            let delta = glucoseObjects.count >= 2 ? lastReading - secondLastReading : nil
-            let body = glucoseText(
-                glucoseValue: Int(lastReading),
-                delta: Int(delta ?? 0),
-                direction: lastDirection
-            ) + infoBody()
-
-            if snoozeUntilDate > Date() {
-                titles.append(String(localized: "(Snoozed)", comment: "(Snoozed)"))
-                notificationAlarm = false
-            } else {
-                let token = alertToken(from: glucoseObjects.first)
-
-                if token == "unknown" {
-                    warning(.service, "Missing glucose token fields; skipping notification to avoid re-alerting")
-                    return
-                }
-                if notificationAlarm, token == lastGlucoseAlertToken {
-                    return
-                }
-                titles.append(body)
-                let content = UNMutableNotificationContent()
-                content.title = titles.joined(separator: " ")
-                content.body = body
-
-                if notificationAlarm {
-                    content.sound = .default
-                    content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
-                    content.categoryIdentifier = NotificationCategoryIdentifier.trioAlert.rawValue
-                }
-
-                addRequest(
-                    identifier: .glucoseNotification,
-                    content: content,
-                    deleteOld: true,
-                    messageType: messageType,
-                    messageSubtype: .glucose,
-                    action: NotificationAction.snooze
-                )
-                if notificationAlarm {
-                    lastGlucoseAlertToken = token
-                }
-            }
+            }.first?.glucose
+            addAppBadge(glucose: latest.map { Int($0) })
         } catch {
-            debugPrint(
-                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to send glucose notification with error: \(error)"
-            )
+            debug(.service, "Failed to update glucose badge: \(error)")
         }
     }
 
-    private func alertToken(from glucose: GlucoseStored?) -> String {
-        if let id = glucose?.id?.uuidString { return id }
-
-        if let date = glucose?.date {
-            let roundedMinute = Int((date.timeIntervalSince1970 / 60).rounded())
-            return "date-\(roundedMinute)"
-        }
-
-        // Stable fallback for Core Data objects:
-        if let glucose, !glucose.objectID.isTemporaryID {
-            return "objectID-\(glucose.objectID.uriRepresentation().absoluteString)"
-        }
-
-        // Stable “unknown” fallback: prevents repeated alarms when identifiers are missing
-        return "unknown"
-    }
-
-    private func glucoseText(glucoseValue: Int, delta: Int?, direction: String?) -> String {
-        let units = settingsManager.settings.units
-        let glucoseText = glucoseFormatter
-            .string(from: Double(
-                units == .mmolL ? glucoseValue
-                    .asMmolL : Decimal(glucoseValue)
-            ) as NSNumber)! + " " + String(localized: "\(units.rawValue)", comment: "units")
-        let directionText = direction ?? "↔︎"
-        let deltaText = delta
-            .map {
-                self.deltaFormatter
-                    .string(from: Double(
-                        units == .mmolL ? $0
-                            .asMmolL : Decimal($0)
-                    ) as NSNumber)!
-            } ?? "--"
-
-        return glucoseText + " " + directionText + " " + deltaText
-    }
-
-    private func infoBody() -> String {
-        var body = ""
-
-        if settingsManager.settings.addSourceInfoToGlucoseNotifications,
-           let info = sourceInfoProvider.sourceInfo()
-        {
-            // Description
-            if let description = info[GlucoseSourceKey.description.rawValue] as? String {
-                body.append("\n" + description)
-            }
-
-            // NS ping
-            if let ping = info[GlucoseSourceKey.nightscoutPing.rawValue] as? TimeInterval {
-                body.append(
-                    "\n"
-                        + String(
-                            format: String(localized: "Nightscout ping: %d ms", comment: "Nightscout ping"),
-                            Int(ping * 1000)
-                        )
-                )
-            }
-
-            // Transmitter battery
-            if let transmitterBattery = info[GlucoseSourceKey.transmitterBattery.rawValue] as? Int {
-                body.append(
-                    "\n"
-                        + String(
-                            format: String(localized: "Transmitter: %@%%", comment: "Transmitter: %@%%"),
-                            "\(transmitterBattery)"
-                        )
-                )
-            }
-        }
-        return body
-    }
-
     func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void) {
         notificationCenter.getNotificationSettings { settings in
             DispatchQueue.main.async {
@@ -470,17 +203,12 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
+    /// Forwards to the canonical snooze entry point on `TrioAlertManager`.
+    /// All snooze surfaces (this method via UN actions / Watch / Snooze
+    /// module / in-app banner) converge there so persistent state, mute
+    /// window, and observers stay in sync.
     @MainActor func applySnooze(for duration: TimeInterval) async {
-        let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
-        snoozeUntilDate = untilDate
-        lastGlucoseAlertToken = ""
-        // removeGlucoseNotifications() is safe to call here since we're @MainActor
-        removeGlucoseNotifications()
-
-        // Notify observers that snooze was applied
-        broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in
-            observer.snoozeDidChange(untilDate)
-        }
+        await trioAlertManager.applySnooze(for: duration)
     }
 
     private func addRequest(
@@ -502,8 +230,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
             action: action
         )
         var alertIdentifier = identifier.rawValue
-        alertIdentifier = identifier == .pumpNotification ? alertIdentifier + content
-            .title : (identifier == .alertMessageNotification ? alertIdentifier + content.body : alertIdentifier)
+        if identifier == .alertMessageNotification {
+            alertIdentifier += content.body
+        }
         if deleteOld {
             DispatchQueue.main.async {
                 self.notificationCenter.removeDeliveredNotifications(withIdentifiers: [alertIdentifier])
@@ -514,7 +243,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
             router.alertMessage.send(messageCont)
             return
         }
-        guard router.allowNotify(messageCont, settingsManager.settings) else { return }
 
         let request = UNNotificationRequest(identifier: alertIdentifier, content: content, trigger: trigger)
         DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
@@ -528,32 +256,15 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
             }
         }
     }
-
-    private var glucoseFormatter: NumberFormatter {
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 0
-        if settingsManager.settings.units == .mmolL {
-            formatter.minimumFractionDigits = 1
-            formatter.maximumFractionDigits = 1
-        }
-        formatter.roundingMode = .halfUp
-        return formatter
-    }
-
-    private var deltaFormatter: NumberFormatter {
-        let formatter = NumberFormatter()
-        formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 1
-        formatter.positivePrefix = "+"
-        return formatter
-    }
 }
 
 extension BaseUserNotificationsManager: alertMessageNotificationObserver {
     func alertMessageNotification(_ message: MessageContent) {
         let content = UNMutableNotificationContent()
-        var identifier: Identifier = .alertMessageNotification
+        // 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 {
@@ -569,27 +280,6 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
         } else {
             content.title = message.title
         }
-        switch message.subtype {
-        case .pump:
-            if message.type == .info || message.type == .error {
-                identifier = Identifier.alertMessageNotification
-            } else {
-                identifier = .pumpNotification
-            }
-        case .carb:
-            identifier = .carbsRequiredNotification
-        case .glucose:
-            identifier = .glucoseNotification
-        case .algorithm:
-            if message.trigger != nil {
-                identifier = message.content.contains(String(firstInterval)) ? Identifier.noLoopFirstNotification : Identifier
-                    .noLoopSecondNotification
-            } else {
-                identifier = Identifier.alertMessageNotification
-            }
-        default:
-            identifier = .alertMessageNotification
-        }
         switch message.action {
         case .snooze:
             content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
@@ -612,68 +302,17 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
     }
 }
 
-extension BaseUserNotificationsManager: pumpNotificationObserver {
-    func pumpNotification(alert: AlertEntry) {
-        let content = UNMutableNotificationContent()
-        let alertUp = alert.alertIdentifier.uppercased()
-        let typeMessage: MessageType
-        if alertUp.contains("FAULT") || alertUp.contains("ERROR") {
-            content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue
-            typeMessage = .error
-        } else {
-            typeMessage = .warning
-            guard settingsManager.settings.notificationsPump else { return }
-        }
-        content.title = alert.contentTitle ?? "Unknown"
-        content.body = alert.contentBody ?? "Unknown"
-        content.sound = .default
-        addRequest(
-            identifier: .pumpNotification,
-            content: content,
-            deleteOld: true,
-            trigger: nil,
-            messageType: typeMessage,
-            messageSubtype: .pump,
-            action: .pumpConfig
-        )
-    }
-
-    func pumpRemoveNotification() {
-        let identifier: Identifier = .pumpNotification
-        DispatchQueue.main.async {
-            self.notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.rawValue])
-            self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue])
-        }
-    }
-
-    /// Removes all glucose notifications (delivered and pending).
-    /// Must be called from the main thread. Safe to call from @MainActor contexts.
-    @MainActor private func removeGlucoseNotifications() {
-        let identifier = Identifier.glucoseNotification.rawValue
-        notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
-        notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
-    }
-}
-
-extension BaseUserNotificationsManager: DeterminationObserver {
-    func determinationDidUpdate(_ determination: Determination) {
-        guard let carndRequired = determination.carbsReq else { return }
-        notifyCarbsRequired(Int(carndRequired))
-    }
-}
-
-extension BaseUserNotificationsManager: BolusFailureObserver {
-    func bolusDidFail() {
-        notifyBolusFailure()
-    }
-}
-
 extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
     func userNotificationCenter(
         _: UNUserNotificationCenter,
-        willPresent _: UNNotification,
+        willPresent notification: UNNotification,
         withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
     ) {
+        let userInfo = notification.request.content.userInfo
+        if userInfo[AlertUserInfoKey.managerIdentifier.rawValue] is String {
+            completionHandler([.badge, .list])
+            return
+        }
         completionHandler([.banner, .badge, .sound, .list])
     }
 
@@ -686,6 +325,12 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
     ) {
         defer { completionHandler() }
 
+        let userInfo = response.notification.request.content.userInfo
+        if userInfo[AlertUserInfoKey.managerIdentifier.rawValue] is String {
+            trioAlertManager.handleNotificationResponse(response)
+            return
+        }
+
         // Handle quick snooze actions (from notification action buttons)
         if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) {
             Task { @MainActor [weak self] in

+ 0 - 4
Trio/Sources/Views/SettingInputSection.swift

@@ -90,10 +90,6 @@ struct SettingInputSection<VerboseHint: View>: View {
     // Helper function to retrieve PickerSetting based on key
     private func getPickerSetting(for key: String) -> PickerSetting? {
         switch key {
-        case "lowGlucose":
-            return pickerSettingsProvider.settings.lowGlucose
-        case "highGlucose":
-            return pickerSettingsProvider.settings.highGlucose
         case "carbsRequiredThreshold":
             return pickerSettingsProvider.settings.carbsRequiredThreshold
         case "individualAdjustmentFactor":

+ 130 - 0
TrioTests/AlertCatalogRegistryEntriesTests.swift

@@ -0,0 +1,130 @@
+import Foundation
+import LoopKit
+import Testing
+
+@testable import Trio
+
+/// Pins `AlertCatalogRegistry.entries` and the static, exact-match
+/// `lookup(_:)` behavior. Pump plugins emit at LoopKit's default
+/// (`.timeSensitive`); this table is the sole escalation source. An unknown
+/// `(manager, alertId)` returns nil (pass-through). The Omni hex-fault parser
+/// is covered separately in `AlertCatalogRegistryOmniFaultTests`.
+@Suite("TrioAlert: CatalogRegistry — static exact-match entries") struct AlertCatalogRegistryEntriesTests {
+    private func id(_ manager: String, _ alertID: String) -> Alert.Identifier {
+        Alert.Identifier(managerIdentifier: manager, alertIdentifier: alertID)
+    }
+
+    // MARK: - A: critical entries
+
+    @Test(
+        "Critical entries escalate to .critical",
+        arguments: [
+            ("Omni", "unexpectedAlert"),
+            ("Minimed", "PumpReservoirEmpty"),
+            ("Dana", "batteryZeroPercent"),
+            ("Dana", "pumpError"),
+            ("Dana", "occlusion"),
+            ("Dana", "shutdown"),
+            ("Dana", "emptyReservoir"),
+            ("Dana", "checkShaft"),
+            // Both spellings are registered: MedtrumKit ships the misspelled
+            // "patch-occlussion" (double-s) today; "patch-occlusion" is
+            // forward-compat for when the upstream typo is fixed. Both escalate.
+            ("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")
+        ]
+    ) func criticalEntries(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID))?.interruptionLevel == .critical)
+    }
+
+    // MARK: - B: representative .timeSensitive entries
+
+    @Test(
+        "Representative entries land at .timeSensitive",
+        arguments: [
+            ("Omni", "lowReservoir"),
+            ("Minimed", "PumpBatteryLow"),
+            ("Dana", "lowBattery"),
+            ("Dana", "unknown"),
+            ("Medtrum", "com.nightscout.medtrumkit.reservoir-low"),
+            ("Medtrum", "com.nightscout.medtrumkit.patch-daily-limit")
+        ]
+    ) func timeSensitiveEntries(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID))?.interruptionLevel == .timeSensitive)
+    }
+
+    // MARK: - C: representative .active entries
+
+    @Test(
+        "Representative entries stay at .active",
+        arguments: [
+            ("Omni", "userPodExpiration"),
+            ("Dana", "basalCompare"),
+            ("Medtrum", "com.nightscout.medtrumkit.patch-expired")
+        ]
+    ) func activeEntries(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID))?.interruptionLevel == .active)
+    }
+
+    // MARK: - D: unknown identifiers pass through (nil)
+
+    @Test(
+        "Unknown identifiers return nil (pass-through)",
+        arguments: [
+            ("Dana", "totallyMadeUp"),
+            ("NoSuchManager", "lowReservoir"),
+            ("", "")
+        ]
+    ) func unknownReturnsNil(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID)) == nil)
+    }
+
+    // MARK: - E: wrong manager, right alert id → nil
+
+    @Test(
+        "Right alert id under the wrong manager returns nil",
+        arguments: [
+            ("Minimed", "unexpectedAlert"),
+            ("Dana", "PumpReservoirEmpty")
+        ]
+    ) func wrongManagerRightID(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID)) == nil)
+    }
+
+    // MARK: - F: right manager, wrong alert id → nil
+
+    // Dana's occlusion entry is the single-s "occlusion"; the double-s
+    // "occlussion" is not a Dana key and must return nil. (The double-s
+    // spelling IS a valid key under the Medtrum manager — see test A — so this
+    // also confirms entries are scoped per-manager.)
+    @Test(
+        "Wrong alert id under the right manager returns nil",
+        arguments: [
+            ("Dana", "occlussion"), // double-s is not a Dana key — must NOT match
+            ("Medtrum", "patch-fault"), // missing com.nightscout.medtrumkit. prefix
+            ("Omni", "lowReservoir ") // trailing space
+        ]
+    ) func rightManagerWrongID(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID)) == nil)
+    }
+
+    // MARK: - G: manager-scoped lowRLBattery
+
+    @Test("lowRLBattery resolves per-manager for Omni and Minimed") func lowRLBatteryIsManagerScoped() {
+        let omni = AlertCatalogRegistry.lookup(id("Omni", "lowRLBattery"))
+        let minimed = AlertCatalogRegistry.lookup(id("Minimed", "lowRLBattery"))
+        #expect(omni != nil)
+        #expect(minimed != nil)
+        #expect(omni?.identifier.managerIdentifier == "Omni")
+        #expect(minimed?.identifier.managerIdentifier == "Minimed")
+    }
+
+    // MARK: - H: invariant — no duplicate manager+alert keys
+
+    @Test("entries have unique manager+alert identifiers") func entriesHaveUniqueKeys() {
+        let keys = Set(AlertCatalogRegistry.entries.map(\.identifier))
+        #expect(keys.count == AlertCatalogRegistry.entries.count)
+    }
+}

+ 60 - 0
TrioTests/AlertCatalogRegistryOmniFaultTests.swift

@@ -0,0 +1,60 @@
+import Foundation
+import LoopKit
+import Testing
+
+@testable import Trio
+
+/// Pins `AlertCatalogRegistry.lookup` behavior for Omni pod-fault identifiers.
+/// `OmniPumpManager` builds `Alert.Identifier` from the formatted
+/// `FaultEventCode.description` string ("Fault Event Code 0xNN: ..."), so the
+/// registry has to parse the hex code out of that prefix at lookup time.
+@Suite("TrioAlert: CatalogRegistry — Omni pod-fault hex parser") struct AlertCatalogRegistryOmniFaultTests {
+    private func omniFaultID(_ desc: String) -> Alert.Identifier {
+        Alert.Identifier(managerIdentifier: "Omni:pumpFault", alertIdentifier: desc)
+    }
+
+    @Test("0x14 → occlusion concept, critical, Pod Occlusion title") func code0x14Occlusion() {
+        let entry = AlertCatalogRegistry.lookup(omniFaultID("Fault Event Code 0x14: Occluded"))
+        #expect(entry?.concept == .occlusion)
+        #expect(entry?.interruptionLevel == .critical)
+        #expect(entry?.title == "Pod Occlusion")
+    }
+
+    @Test("0x18 → reservoirEmpty concept, critical") func code0x18ReservoirEmpty() {
+        let entry = AlertCatalogRegistry.lookup(omniFaultID("Fault Event Code 0x18: Reservoir empty"))
+        #expect(entry?.concept == .reservoirEmpty)
+        #expect(entry?.interruptionLevel == .critical)
+    }
+
+    @Test("0x1C → deviceExpired concept, timeSensitive") func code0x1CExpired() {
+        let entry = AlertCatalogRegistry.lookup(
+            omniFaultID("Fault Event Code 0x1c: Exceeded maximum Pod life of 80 hours")
+        )
+        #expect(entry?.concept == .deviceExpired)
+        #expect(entry?.interruptionLevel == .timeSensitive)
+    }
+
+    @Test("Unmapped fault code falls back to generic hardwareFault") func unknownCodeFallback() {
+        let entry = AlertCatalogRegistry.lookup(omniFaultID("Fault Event Code 0x42: Some internal fault"))
+        #expect(entry?.concept == .hardwareFault)
+        #expect(entry?.interruptionLevel == .critical)
+        #expect(entry?.title == "Pod Fault")
+    }
+
+    @Test("Malformed hex (non-hex chars) falls back to hardwareFault") func malformedHex() {
+        let entry = AlertCatalogRegistry.lookup(omniFaultID("Fault Event Code 0xZZ: garbled"))
+        #expect(entry?.concept == .hardwareFault)
+    }
+
+    @Test("Truncated identifier still resolves to hardwareFault") func truncatedIdentifier() {
+        let entry = AlertCatalogRegistry.lookup(omniFaultID("Fault Event Code 0x"))
+        #expect(entry?.concept == .hardwareFault)
+    }
+
+    @Test("Non-Omni managerIdentifier is ignored by the fault parser") func wrongManagerIgnored() {
+        let id = Alert.Identifier(managerIdentifier: "Dana", alertIdentifier: "Fault Event Code 0x14: Occluded")
+        // Dana doesn't carry this slug in the catalog, so lookup is nil even
+        // though the alert text matches the Omni pattern.
+        #expect(AlertCatalogRegistry.lookup(id) == nil)
+    }
+}

+ 0 - 0
TrioTests/AlertMuterTests.swift


Некоторые файлы не были показаны из-за большого количества измененных файлов