فهرست منبع

Glucose Alarms: user-configurable list of alarms

* GlucoseAlertsStore (UserDefaults) holds [GlucoseAlert] +
  GlucoseAlertConfiguration. Seeds 4 default alarms (urgent-low +
  forecasted-low disabled, low + high enabled) on first launch. At
  least one alarm of each type is retained (delete-protected).
* GlucoseAlertCoordinator evaluates reading-based + forecast-based
  alarms, issues via TrioAlertManager, retracts on recovery.
  Hierarchical suppression: urgent-low silences low; urgent-low / low
  silence forecasted-low.
* ForecastedGlucoseEvaluator: min of available predBG curves at +20m.
* SwiftUI module GlucoseAlerts — alarm list, inline Day/Night
  DatePickers, per-type editor, AlarmAudioSection with tone preview and
  Play Sound toggle, window icon per row, Override Silence & DND toggle.
* Retire GlucoseNotificationSettings module, GlucoseNotificationsOption
  enum, lowGlucose / highGlucose / addSourceInfoToGlucoseNotifications
  fields. ContactImage and LiveActivity now read settings.low / .high.
* sendGlucoseNotification reduced to badge updates; alarm emission owned
  by the coordinator.
Deniz Cengiz 4 هفته پیش
والد
کامیت
380c672d8b
44فایلهای تغییر یافته به همراه1964 افزوده شده و 752 حذف شده
  1. 93 29
      Trio.xcodeproj/project.pbxproj
  2. 2 2
      Trio/Sources/APS/Storage/GlucoseStorage.swift
  3. 1 1
      Trio/Sources/Application/TrioApp.swift
  4. 1 1
      Trio/Sources/Assemblies/ServiceAssembly.swift
  5. 335 2
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  6. 0 2
      Trio/Sources/Models/DecimalPickerSettings.swift
  7. 34 0
      Trio/Sources/Models/GlucoseAlerts/AlarmSoundCatalog.swift
  8. 33 0
      Trio/Sources/Models/GlucoseAlerts/DayNightOptions.swift
  9. 69 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlert.swift
  10. 43 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlertConfiguration.swift
  11. 67 0
      Trio/Sources/Models/GlucoseAlerts/GlucoseAlertType.swift
  12. 0 27
      Trio/Sources/Models/GlucoseNotificationsOption.swift
  13. 0 26
      Trio/Sources/Models/TrioSettings.swift
  14. 7 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsDataFlow.swift
  15. 5 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsProvider.swift
  16. 20 0
      Trio/Sources/Modules/GlucoseAlerts/GlucoseAlertsStateModel.swift
  17. 46 0
      Trio/Sources/Modules/GlucoseAlerts/View/AddGlucoseAlertSheet.swift
  18. 16 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmActiveSection.swift
  19. 167 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmAudioSection.swift
  20. 54 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmBGSection.swift
  21. 39 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmEnumMenuPicker.swift
  22. 44 0
      Trio/Sources/Modules/GlucoseAlerts/View/Components/AlarmWindowIcon.swift
  23. 155 0
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertEditorView.swift
  24. 307 0
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift
  25. 0 5
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsDataFlow.swift
  26. 0 3
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsProvider.swift
  27. 0 43
      Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift
  28. 0 285
      Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift
  29. 2 2
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivityWidgetConfiguration.swift
  30. 0 2
      Trio/Sources/Modules/Onboarding/OnboardingStateModel.swift
  31. 11 15
      Trio/Sources/Modules/Settings/SettingItems.swift
  32. 2 2
      Trio/Sources/Modules/Settings/View/Subviews/NotificationsView.swift
  33. 0 31
      Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
  34. 5 2
      Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift
  35. 5 6
      Trio/Sources/Router/Router.swift
  36. 3 3
      Trio/Sources/Router/Screen.swift
  37. 0 77
      Trio/Sources/Services/Alerts/ForecastedGlucoseAlertObserver.swift
  38. 289 0
      Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift
  39. 98 0
      Trio/Sources/Services/Alerts/GlucoseAlertsStore.swift
  40. 3 1
      Trio/Sources/Services/Alerts/TrioAlertManager.swift
  41. 2 2
      Trio/Sources/Services/ContactImage/ContactImageManager.swift
  42. 1 9
      Trio/Sources/Services/Storage/FileStorage.swift
  43. 5 170
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift
  44. 0 4
      Trio/Sources/Views/SettingInputSection.swift

+ 93 - 29
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 */; };
@@ -186,7 +184,24 @@
 		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 */; };
-		BD1179422F4E22C100F90001 /* ForecastedGlucoseAlertObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179432F4E22C100F90001 /* ForecastedGlucoseAlertObserver.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 */; };
+		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 */; };
 		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 */; };
@@ -342,7 +357,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 */; };
@@ -489,7 +503,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 */; };
@@ -748,7 +761,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 */; };
@@ -938,10 +950,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>"; };
@@ -1085,7 +1095,24 @@
 		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>"; };
-		BD1179432F4E22C100F90001 /* ForecastedGlucoseAlertObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForecastedGlucoseAlertObserver.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>"; };
+		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>"; };
 		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>"; };
@@ -1219,7 +1246,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>"; };
@@ -1423,7 +1449,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>"; };
@@ -1634,7 +1659,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>"; };
@@ -2013,7 +2037,7 @@
 				195D80B22AF696EE00D25097 /* DynamicSettings */,
 				C2AA6CF52E1A734A00BF6C16 /* SettingsExport */,
 				DD17454C2C55CA0200211FAC /* GeneralSettings */,
-				F66B236E00924A05D6A9F9DF /* GlucoseNotificationSettings */,
+				BD1179602F4E22C100F90001 /* GlucoseAlerts */,
 				F90692CD274B99850037068D /* HealthKit */,
 				3811DE2725C9D49500A708ED /* Home */,
 				19E1F7E629D0828B005C8D20 /* IconConfig */,
@@ -2179,8 +2203,9 @@
 			isa = PBXGroup;
 			children = (
 				BD1179312F4E22C100F90001 /* CriticalAlertAudioPlayer.swift */,
-				BD1179432F4E22C100F90001 /* ForecastedGlucoseAlertObserver.swift */,
 				BD1179412F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift */,
+				BD11795D2F4E22C100F90001 /* GlucoseAlertCoordinator.swift */,
+				BD1179592F4E22C100F90001 /* GlucoseAlertsStore.swift */,
 				BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */,
 				BD1179212F4E22C100F90001 /* TrioAlertManager.swift */,
 				BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */,
@@ -2521,9 +2546,22 @@
 			path = OpenAPS;
 			sourceTree = "<group>";
 		};
+		BD11795B2F4E22C100F90001 /* GlucoseAlerts */ = {
+			isa = PBXGroup;
+			children = (
+				BD11797C2F4E22C100F90001 /* AlarmSoundCatalog.swift */,
+				BD1179512F4E22C100F90001 /* DayNightOptions.swift */,
+				BD1179532F4E22C100F90001 /* GlucoseAlert.swift */,
+				BD1179552F4E22C100F90001 /* GlucoseAlertConfiguration.swift */,
+				BD1179572F4E22C100F90001 /* GlucoseAlertType.swift */,
+			);
+			path = GlucoseAlerts;
+			sourceTree = "<group>";
+		};
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				BD11795B2F4E22C100F90001 /* GlucoseAlerts */,
 				49C782A62F73D9870062B0DD /* AlertEntry.swift */,
 				DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */,
 				3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */,
@@ -2549,7 +2587,6 @@
 				49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */,
 				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
 				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
-				715120D12D3C2B84005D9FB6 /* GlucoseNotificationsOption.swift */,
 				E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */,
 				1967DFBD29D052C200759F30 /* Icons.swift */,
 				382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */,
@@ -3946,23 +3983,38 @@
 			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>";
+		};
+		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 */,
+				BD1179722F4E22C100F90001 /* AlarmEnumMenuPicker.swift */,
+				BD11797E2F4E22C100F90001 /* AlarmWindowIcon.swift */,
 			);
-			path = GlucoseNotificationSettings;
+			path = Components;
 			sourceTree = "<group>";
 		};
 		F75CB57ED6971B46F8756083 /* CGMSettings */ = {
@@ -4627,7 +4679,24 @@
 				BD1179322F4E22C100F90001 /* CriticalAlertAudioPlayer.swift in Sources */,
 				BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */,
 				BD1179402F4E22C100F90001 /* ForecastedGlucoseEvaluator.swift in Sources */,
-				BD1179422F4E22C100F90001 /* ForecastedGlucoseAlertObserver.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 */,
+				BD1179772F4E22C100F90001 /* AlarmActiveSection.swift in Sources */,
+				BD1179792F4E22C100F90001 /* AlarmAudioSection.swift in Sources */,
+				BD11797B2F4E22C100F90001 /* AlarmSoundCatalog.swift in Sources */,
+				BD11797D2F4E22C100F90001 /* AlarmWindowIcon.swift in Sources */,
 				DD3F1F902D9E153F00DCE7B3 /* NightscoutImportStepView.swift in Sources */,
 				CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */,
 				DD1745372C55B74200211FAC /* AlgorithmSettings.swift in Sources */,
@@ -4863,7 +4932,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 */,
@@ -4931,9 +4999,6 @@
 				320D030F724170A637F06D50 /* (null) 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 */,
 				DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */,
 				DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */,
@@ -4972,7 +5037,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 */,

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

@@ -621,11 +621,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
                 }
 

+ 1 - 1
Trio/Sources/Application/TrioApp.swift

@@ -77,7 +77,6 @@ extension Notification.Name {
         _ = resolver.resolve(FetchTreatmentsManager.self)!
         _ = resolver.resolve(CalendarManager.self)!
         _ = resolver.resolve(UserNotificationsManager.self)!
-        _ = resolver.resolve(ForecastedGlucoseAlertObserver.self)!
         _ = resolver.resolve(WatchManager.self)!
         _ = resolver.resolve(ContactImageManager.self)!
         _ = resolver.resolve(HealthKitManager.self)!
@@ -91,6 +90,7 @@ extension Notification.Name {
             _ = resolver.resolve(LiveActivityManager.self)!
         }
         _ = resolver.resolve(IOBService.self)!
+        _ = resolver.resolve(GlucoseAlertCoordinator.self)!
     }
 
     init() {

+ 1 - 1
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -19,7 +19,7 @@ final class ServiceAssembly: Assembly {
         container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
         container.register(TrioAlertManager.self) { r in BaseTrioAlertManager(resolver: r) }
             .inObjectScope(.container)
-        container.register(ForecastedGlucoseAlertObserver.self) { r in ForecastedGlucoseAlertObserver(resolver: r) }
+        container.register(GlucoseAlertCoordinator.self) { r in GlucoseAlertCoordinator(resolver: r) }
             .inObjectScope(.container)
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }

+ 335 - 2
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -6476,6 +6476,7 @@
     },
     "(Snoozed)" : {
       "comment" : "(Snoozed)",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -7485,7 +7486,7 @@
       }
     },
     "%@" : {
-      "comment" : "Info message\nunits",
+      "comment" : "Info message",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -7993,6 +7994,18 @@
         }
       }
     },
+    "%@ – %@" : {
+      "comment" : "A description of the current day/night glucose alert configuration.",
+      "isCommentAutoGenerated" : true,
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "%1$@ – %2$@"
+          }
+        }
+      }
+    },
     "%@ (%@-%@)" : {
       "localizations" : {
         "bg" : {
@@ -10171,6 +10184,18 @@
         }
       }
     },
+    "%lld %@" : {
+      "comment" : "A label displaying the current value of a stepper. The value is automatically updated as the user adjusts the stepper.",
+      "isCommentAutoGenerated" : true,
+      "localizations" : {
+        "en" : {
+          "stringUnit" : {
+            "state" : "new",
+            "value" : "%1$lld %2$@"
+          }
+        }
+      }
+    },
     "%lld %%" : {
       "localizations" : {
         "bg" : {
@@ -10791,6 +10816,10 @@
         }
       }
     },
+    "%lld sec" : {
+      "comment" : "A pluralized string that describes a number of seconds.",
+      "isCommentAutoGenerated" : true
+    },
     "%lld x" : {
       "localizations" : {
         "bg" : {
@@ -26484,6 +26513,7 @@
       }
     },
     "A notification will be triggered every time your glucose is updated in Trio." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -26608,6 +26638,7 @@
       }
     },
     "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." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -27483,6 +27514,10 @@
         }
       }
     },
+    "above" : {
+      "comment" : "A comparison operator.",
+      "isCommentAutoGenerated" : true
+    },
     "Acknowledge all alerts" : {
       "localizations" : {
         "bg" : {
@@ -28105,6 +28140,14 @@
         }
       }
     },
+    "Active" : {
+      "comment" : "Title of a section that allows the user to select whether or not the glucose alert is active during that time of day.",
+      "isCommentAutoGenerated" : true
+    },
+    "Active During" : {
+      "comment" : "A section header for the active glucose alert.",
+      "isCommentAutoGenerated" : true
+    },
     "Active Time" : {
       "comment" : "description label for active time pod details rowdescription label for active time pod details row",
       "extractionState" : "manual",
@@ -28867,6 +28910,10 @@
         }
       }
     },
+    "Add Alarm" : {
+      "comment" : "A title for a sheet that lets the user add a glucose alert.",
+      "isCommentAutoGenerated" : true
+    },
     "Add and set a bolus option for meals that absorb quickly." : {
       "localizations" : {
         "bg" : {
@@ -33803,6 +33850,9 @@
         }
       }
     },
+    "Adds your current glucose to the top right of your Trio icon as a red notification badge. Takes effect on the next glucose reading." : {
+
+    },
     "Adjust basal" : {
       "comment" : "Headline \"Adjust basal\"",
       "extractionState" : "manual",
@@ -36076,6 +36126,10 @@
         }
       }
     },
+    "Alarm" : {
+      "comment" : "Name of a critical-alarm sound file.",
+      "isCommentAutoGenerated" : true
+    },
     "Alarms" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -36207,6 +36261,18 @@
         }
       }
     },
+    "Alert Sound" : {
+      "comment" : "A section header for the alert sound settings.",
+      "isCommentAutoGenerated" : true
+    },
+    "Alert when any reading at or above this value persists for the chosen window." : {
+      "comment" : "Text displayed in a section header for a high glucose alert.",
+      "isCommentAutoGenerated" : true
+    },
+    "Alert when any reading at or below this value persists for the chosen window." : {
+      "comment" : "Footer for the low glucose alert section.",
+      "isCommentAutoGenerated" : true
+    },
     "Algorithm" : {
       "comment" : "Algorithm menu item in the Settings main view.",
       "localizations" : {
@@ -42033,6 +42099,7 @@
     },
     "Always" : {
       "comment" : "Option to always notify on every CGM reading",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -43668,6 +43735,7 @@
       }
     },
     "Always:" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -45676,6 +45744,10 @@
         }
       }
     },
+    "App Badge" : {
+      "comment" : "Section title for the glucose app badge setting.",
+      "isCommentAutoGenerated" : true
+    },
     "App diagnostic insights — based on crash reports only — help us enhance app stability, ensure safety for all users, and quickly identify and resolve critical issues." : {
 
     },
@@ -46552,6 +46624,14 @@
         }
       }
     },
+    "Appends the CGM source name to glucose notification text." : {
+      "comment" : "Verbose hint for the \"Add Glucose Source to Alarm\" setting.",
+      "isCommentAutoGenerated" : true
+    },
+    "Appends the source of the glucose reading (e.g. Dexcom G6) to the notification body." : {
+      "comment" : "A description of how the glucose source is appended to the glucose notification.",
+      "isCommentAutoGenerated" : true
+    },
     "Apple Health" : {
       "localizations" : {
         "bg" : {
@@ -56478,6 +56558,10 @@
         }
       }
     },
+    "below" : {
+      "comment" : "A label describing a glucose value is below.",
+      "isCommentAutoGenerated" : true
+    },
     "Below is the exact JSON object Trio would send right now. No glucose, insulin, carbs, credentials, or settings values are included." : {
 
     },
@@ -57233,6 +57317,14 @@
         }
       }
     },
+    "Bloom" : {
+      "comment" : "Name of a sound file.",
+      "isCommentAutoGenerated" : true
+    },
+    "Bloop" : {
+      "comment" : "Name of a sound file.",
+      "isCommentAutoGenerated" : true
+    },
     "Bluetooth" : {
       "localizations" : {
         "bg" : {
@@ -61419,6 +61511,10 @@
         }
       }
     },
+    "Bright Alarm" : {
+      "comment" : "Name of the sound to be played when the alarm is set to \"Bright Alarm\".",
+      "isCommentAutoGenerated" : true
+    },
     "Build Number" : {
       "localizations" : {
         "bg" : {
@@ -70012,6 +70108,10 @@
       "comment" : "Error message displayed when a bolus fails.",
       "isCommentAutoGenerated" : true
     },
+    "Chime" : {
+      "comment" : "Name of a sound file.",
+      "isCommentAutoGenerated" : true
+    },
     "Choose a value that covers your highest insulin needs — think about a correction for a very high glucose reading plus your biggest meal bolus. This gives Trio room to work while keeping you safe." : {
       "localizations" : {
         "bg" : {
@@ -70633,6 +70733,7 @@
       }
     },
     "Choose glucose notifications option. See hint for more details." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -71252,6 +71353,10 @@
         }
       }
     },
+    "Choose Tone" : {
+      "comment" : "A title for a sheet that lets the user choose an alarm tone.",
+      "isCommentAutoGenerated" : true
+    },
     "Choose Trio's appearance. See hint for more details." : {
       "localizations" : {
         "bg" : {
@@ -72252,6 +72357,10 @@
         }
       }
     },
+    "Clear Chimes" : {
+      "comment" : "Display name for the clear chimes alarm sound.",
+      "isCommentAutoGenerated" : true
+    },
     "Clears the local App Attest key, registered flag, and forbidden flag. The next telemetry send will re-attest from scratch. Use only if telemetry is stuck." : {
 
     },
@@ -73502,6 +73611,9 @@
         }
       }
     },
+    "Color Thresholds" : {
+
+    },
     "Coming soon." : {
       "localizations" : {
         "bg" : {
@@ -79438,6 +79550,14 @@
         }
       }
     },
+    "Critical" : {
+      "comment" : "Name of the critical alarm sound.",
+      "isCommentAutoGenerated" : true
+    },
+    "Crying" : {
+      "comment" : "Name of a sound file that plays a crying sound.",
+      "isCommentAutoGenerated" : true
+    },
     "Current" : {
       "localizations" : {
         "bg" : {
@@ -83337,6 +83457,26 @@
         }
       }
     },
+    "Day & Night" : {
+      "comment" : "Display name for the \"Always\" option in settings.",
+      "isCommentAutoGenerated" : true
+    },
+    "Day & Night Windows" : {
+      "comment" : "A section of the settings screen that allows the user to set the start times for day and night.",
+      "isCommentAutoGenerated" : true
+    },
+    "Day and Night windows are configured globally on the Alarms screen." : {
+      "comment" : "A description of how the day and night windows are configured.",
+      "isCommentAutoGenerated" : true
+    },
+    "Day only" : {
+      "comment" : "A label describing an alarm that is set for the day only.",
+      "isCommentAutoGenerated" : true
+    },
+    "Day Starts" : {
+      "comment" : "A label for the start of the day.",
+      "isCommentAutoGenerated" : true
+    },
     "DayEnd" : {
       "localizations" : {
         "bg" : {
@@ -90837,6 +90977,10 @@
         }
       }
     },
+    "Delay Between Sounds" : {
+      "comment" : "A label displayed under the sound delay slider.",
+      "isCommentAutoGenerated" : true
+    },
     "Delay In Minutes" : {
       "comment" : "Delay",
       "extractionState" : "manual",
@@ -91217,6 +91361,10 @@
         }
       }
     },
+    "Delete Alarm" : {
+      "comment" : "A button that deletes an alarm.",
+      "isCommentAutoGenerated" : true
+    },
     "Delete autotune data" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -95671,6 +95819,10 @@
         }
       }
     },
+    "Dings" : {
+      "comment" : "Name of a sound file.",
+      "isCommentAutoGenerated" : true
+    },
     "Direct connection with Libre 1 transmitters or European Libre 2 sensors" : {
       "comment" : "Direct connection with Libre 1 transmitters or Libre 2",
       "extractionState" : "manual",
@@ -96558,6 +96710,7 @@
       }
     },
     "Disabled:" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -122220,6 +122373,22 @@
       "comment" : "A button that fires a test alert.",
       "isCommentAutoGenerated" : true
     },
+    "Fires when glucose drops to or below a low threshold." : {
+
+    },
+    "Fires when glucose drops to or below an urgent low threshold." : {
+
+    },
+    "Fires when glucose is forecasted to be low within the next 20 minutes." : {
+
+    },
+    "Fires when glucose rises to or above a high threshold." : {
+
+    },
+    "Fires when the forecast at +20 minutes (blended across all available prediction curves) is at or below this value." : {
+      "comment" : "Text displayed in a section header for a glucose alert that fires when the forecast at +20 minutes (blended across all available prediction curves) is at or below the specified threshold.",
+      "isCommentAutoGenerated" : true
+    },
     "Firmware" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -123978,6 +124147,10 @@
         }
       }
     },
+    "Forecast +20 min %1$@, at or below limit %2$@." : {
+      "comment" : "Glucose value is the predicted value for the next 20 minutes.",
+      "isCommentAutoGenerated" : true
+    },
     "Forecast Display Type" : {
       "localizations" : {
         "bg" : {
@@ -124226,6 +124399,9 @@
         }
       }
     },
+    "Forecasted Low Glucose" : {
+
+    },
     "Forecasts" : {
       "localizations" : {
         "bg" : {
@@ -127000,6 +127176,10 @@
         }
       }
     },
+    "General" : {
+      "comment" : "A section of the glucose alert editor that contains the name and enable toggle.",
+      "isCommentAutoGenerated" : true
+    },
     "General Appearance" : {
       "localizations" : {
         "bg" : {
@@ -127876,7 +128056,7 @@
       }
     },
     "Glucose" : {
-      "comment" : "Glucose\nHistory Mode\nTitle for glucose-related statistics",
+      "comment" : "History Mode\nTitle for glucose-related statistics",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -128000,6 +128180,18 @@
         }
       }
     },
+    "Glucose %1$@ at or above %2$@." : {
+      "comment" : "Alert body text for high glucose.",
+      "isCommentAutoGenerated" : true
+    },
+    "Glucose %1$@ at or below %2$@." : {
+      "comment" : "Alert body text for low/urgentLow glucose alerts.",
+      "isCommentAutoGenerated" : true
+    },
+    "Glucose Alarms" : {
+      "comment" : "The title of the glucose alarms screen.",
+      "isCommentAutoGenerated" : true
+    },
     "Glucose and Trend, no Delta" : {
       "comment" : "Live Activity widget icon label for Glucose and Trend, no Delta"
     },
@@ -129003,6 +129195,9 @@
         }
       }
     },
+    "Glucose Display" : {
+
+    },
     "Glucose Distribution" : {
       "localizations" : {
         "bg" : {
@@ -129499,6 +129694,14 @@
         }
       }
     },
+    "Glucose must stay above the threshold this long before alerting. Set 0 to alert immediately." : {
+      "comment" : "Glucose must stay above the threshold this long before alerting. Set 0 to alert immediately.",
+      "isCommentAutoGenerated" : true
+    },
+    "Glucose must stay below the threshold this long before alerting. Set 0 to alert immediately." : {
+      "comment" : "Glucose value that must persist below the set threshold for the alert to trigger.",
+      "isCommentAutoGenerated" : true
+    },
     "Glucose Notification visibility" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -135088,6 +135291,14 @@
         }
       }
     },
+    "High Chimes" : {
+      "comment" : "Display name for the high_chimes.caf alarm sound.",
+      "isCommentAutoGenerated" : true
+    },
+    "High Color Threshold" : {
+      "comment" : "A label displayed under the high glucose color threshold.",
+      "isCommentAutoGenerated" : true
+    },
     "High Contrast Mode" : {
       "localizations" : {
         "bg" : {
@@ -137100,6 +137311,10 @@
         }
       }
     },
+    "Honk" : {
+      "comment" : "Display name for the honk alarm sound.",
+      "isCommentAutoGenerated" : true
+    },
     "Horizontal Scroll View Visible hours" : {
       "comment" : "UI/UX option",
       "extractionState" : "manual",
@@ -141486,6 +141701,7 @@
       }
     },
     "If your CGM readings are below the Low value or above the High value, you will receive a glucose alarm." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -144144,6 +144360,10 @@
         }
       }
     },
+    "Inactive" : {
+      "comment" : "A section title for inactive glucose alerts.",
+      "isCommentAutoGenerated" : true
+    },
     "Include IOB & COB in the calendar event data." : {
       "localizations" : {
         "bg" : {
@@ -158247,6 +158467,10 @@
         }
       }
     },
+    "Look ahead this many minutes in the forecast curves; alert early if any value would dip below the threshold. Set 0 to disable." : {
+      "comment" : "Look ahead this many minutes in the forecast curves; alert early if any value would dip below the threshold. Set 0 to disable.",
+      "isCommentAutoGenerated" : true
+    },
     "Loop at %@ failed." : {
       "localizations" : {
         "bg" : {
@@ -160018,6 +160242,7 @@
       }
     },
     "Low and High Glucose Alarm Limits" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -160397,6 +160622,9 @@
         }
       }
     },
+    "Low Color Threshold" : {
+
+    },
     "Low Glucose" : {
       "localizations" : {
         "bg" : {
@@ -173345,6 +173573,10 @@
         }
       }
     },
+    "Minimal" : {
+      "comment" : "Display name for the minimal alarm sound.",
+      "isCommentAutoGenerated" : true
+    },
     "Minimum duration in minutes for new SMB since last SMB or manual bolus" : {
       "comment" : "\"SMB Interval\"",
       "extractionState" : "manual",
@@ -175639,6 +175871,10 @@
         }
       }
     },
+    "Mood Synth" : {
+      "comment" : "Display name for the \"Mood Synth\" alarm sound.",
+      "isCommentAutoGenerated" : true
+    },
     "More options" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -176906,6 +177142,14 @@
         }
       }
     },
+    "Night only" : {
+      "comment" : "A label for \"Night only\".",
+      "isCommentAutoGenerated" : true
+    },
+    "Night Starts" : {
+      "comment" : "A label for the night start time picker.",
+      "isCommentAutoGenerated" : true
+    },
     "Nightscout" : {
       "localizations" : {
         "bg" : {
@@ -177412,6 +177656,7 @@
     },
     "Nightscout ping: %d ms" : {
       "comment" : "Nightscout ping",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -179165,6 +179410,7 @@
       }
     },
     "No Glucose Notifications will be triggered." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -189364,6 +189610,7 @@
       }
     },
     "Only Alarm Limits:" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -192906,6 +193153,10 @@
         }
       }
     },
+    "Override Silence & Do Not Disturb" : {
+      "comment" : "A toggle that allows the user to override silence and Do Not Disturb.",
+      "isCommentAutoGenerated" : true
+    },
     "Override Target" : {
       "localizations" : {
         "bg" : {
@@ -196842,6 +197093,14 @@
         }
       }
     },
+    "Persistence" : {
+      "comment" : "Title for the section that lets the user set how long a low glucose value must persist before triggering an alert.",
+      "isCommentAutoGenerated" : true
+    },
+    "Persistent" : {
+      "comment" : "\"Persistent\" is a generic term for \"how long\" or \"for how long\".",
+      "isCommentAutoGenerated" : true
+    },
     "Phone NFC required!" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -197097,6 +197356,10 @@
         }
       }
     },
+    "Play" : {
+      "comment" : "Title of a menu item that allows the user to select whether to play the sound of the alarm.",
+      "isCommentAutoGenerated" : true
+    },
     "Please authorize notifications by tapping 'Open iOS Settings' > 'Notifications' and enable 'Allow Notifications' for 'Notification Center' and 'Banners' Alerts." : {
       "localizations" : {
         "bg" : {
@@ -198623,6 +198886,10 @@
         }
       }
     },
+    "Prediction" : {
+      "comment" : "Label for the prediction setting in the glucose alert editor.",
+      "isCommentAutoGenerated" : true
+    },
     "Prediction Type" : {
       "localizations" : {
         "bg" : {
@@ -198871,6 +199138,10 @@
         }
       }
     },
+    "Predictive" : {
+      "comment" : "Label for the number of minutes to look ahead in the forecast for predictive alerts.",
+      "isCommentAutoGenerated" : true
+    },
     "Preferences" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -207673,6 +207944,9 @@
         }
       }
     },
+    "Recommended to always override silence and Do Not Disturb." : {
+
+    },
     "Recommended:" : {
       "comment" : "Recommended bolus on Watch",
       "localizations" : {
@@ -209538,6 +209812,10 @@
         }
       }
     },
+    "Repeat" : {
+      "comment" : "Label for a picker that lets the user select how many times to repeat an alert sound.",
+      "isCommentAutoGenerated" : true
+    },
     "Replace pod" : {
       "comment" : "View/Header when pod expired",
       "localizations" : {
@@ -221832,6 +222110,7 @@
       }
     },
     "Set the Glucose Notifications Option. Descriptions for each option found below." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -222220,6 +222499,7 @@
       }
     },
     "Sets the lower and upper limit for glucose alarms." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -225364,7 +225644,12 @@
         }
       }
     },
+    "Show your current glucose on the Trio app icon." : {
+      "comment" : "Hint for the glucose badge setting.",
+      "isCommentAutoGenerated" : true
+    },
     "Show your current glucose on Trio app icon." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -230882,6 +231167,7 @@
       }
     },
     "Source of the glucose reading will be added to the notification." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -231253,6 +231539,10 @@
         }
       }
     },
+    "Spring" : {
+      "comment" : "Name of a sound effect.",
+      "isCommentAutoGenerated" : true
+    },
     "STALE DATA" : {
       "comment" : "Information displayed when watch app data outdated or stale.",
       "localizations" : {
@@ -238782,6 +239072,10 @@
         }
       }
     },
+    "Synth" : {
+      "comment" : "Display name for the synth alarm sound.",
+      "isCommentAutoGenerated" : true
+    },
     "System Default" : {
       "localizations" : {
         "bg" : {
@@ -248811,6 +249105,7 @@
       }
     },
     "The source of the glucose reading will be added to the notification." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -250058,7 +250353,16 @@
         }
       }
     },
+    "These global times decide when each alarm's Day / Night setting applies." : {
+      "comment" : "A description of the day and night start times.",
+      "isCommentAutoGenerated" : true
+    },
+    "These thresholds set the **color boundaries** for the chart bands, watch widget, and contact image — they don't fire alerts on their own." : {
+      "comment" : "A description of the color thresholds.",
+      "isCommentAutoGenerated" : true
+    },
     "These two settings determine the range outside of which you will be notified via push notifications." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -250182,6 +250486,10 @@
         }
       }
     },
+    "These values drive the watch widget, contact image, and chart band colors. To configure when alarms fire, open Glucose Alarms." : {
+      "comment" : "A description of the glucose thresholds.",
+      "isCommentAutoGenerated" : true
+    },
     "This 15% minimum is a safety limit taken from oref (OpenAPS reference design) and AndroidAPS. It helps prevent insulin delivery from dropping to unsafe levels." : {
       "localizations" : {
         "bg" : {
@@ -257740,6 +258048,7 @@
       }
     },
     "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." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -260987,6 +261296,10 @@
         }
       }
     },
+    "To configure when alarms fire (urgent low, low, forecasted low, high) including thresholds, sounds, schedules, and DND override, open **Glucose Alarms**." : {
+      "comment" : "A description of how to configure glucose alarm thresholds, sounds, schedules, and DND override.",
+      "isCommentAutoGenerated" : true
+    },
     "To configure your CGM, tap the button below. In the form that opens, enter your Nightscout credentials to connect to your instance." : {
       "localizations" : {
         "bg" : {
@@ -262607,6 +262920,10 @@
         }
       }
     },
+    "Tone" : {
+      "comment" : "A label displayed in a list item.",
+      "isCommentAutoGenerated" : true
+    },
     "Top target" : {
       "comment" : "Upper temp target limit",
       "extractionState" : "manual",
@@ -264392,6 +264709,7 @@
     },
     "Transmitter: %@%%" : {
       "comment" : "Transmitter: %@%%",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -264894,6 +265212,10 @@
         }
       }
     },
+    "Trill" : {
+      "comment" : "Name of a sound file.",
+      "isCommentAutoGenerated" : true
+    },
     "Trio" : {
       "localizations" : {
         "bg" : {
@@ -276036,6 +276358,16 @@
         }
       }
     },
+    "Urgent Low" : {
+      "comment" : "Display name for the urgent low alarm sound.",
+      "isCommentAutoGenerated" : true
+    },
+    "Urgent Low Glucose" : {
+
+    },
+    "Urgent Low Threshold" : {
+
+    },
     "URL" : {
       "localizations" : {
         "bg" : {
@@ -278965,6 +279297,7 @@
       }
     },
     "Various Glucose Notifications" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {

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

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

@@ -0,0 +1,69 @@
+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 Do Not Disturb / silent switch / Focus
+    /// 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
+    }
+
+    // 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)
+    }
+}

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

@@ -0,0 +1,43 @@
+import Foundation
+
+struct GlucoseAlertConfiguration: Codable, Equatable {
+    var dayStart: TimeOfDay
+    var nightStart: TimeOfDay
+
+    init(
+        dayStart: TimeOfDay = TimeOfDay(hour: 6, minute: 0),
+        nightStart: TimeOfDay = TimeOfDay(hour: 22, minute: 0)
+    ) {
+        self.dayStart = dayStart
+        self.nightStart = nightStart
+    }
+
+    /// 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
+        }
+    }
+}

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

@@ -0,0 +1,67 @@
+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
+
+    var id: String { rawValue }
+
+    var priority: Int { Self.allCases.firstIndex(of: self) ?? 0 }
+
+    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: "Forecasted Low Glucose")
+        case .high: return String(localized: "High Glucose")
+        }
+    }
+
+    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.")
+        }
+    }
+
+    /// Default mg/dL threshold when adding a new alarm of this type.
+    var defaultThresholdMgDL: Decimal {
+        switch self {
+        case .urgentLow: return 54
+        case .low: return 72
+        case .forecastedLow: return 72
+        case .high: return 270
+        }
+    }
+
+    /// 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"
+        }
+    }
+
+    /// 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 .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")
-        }
-    }
-}

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

@@ -30,10 +30,6 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var displayCalendarIOBandCOB: Bool = false
     var displayCalendarEmojis: Bool = false
     var glucoseBadge: Bool = false
-    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
@@ -209,28 +205,6 @@ extension TrioSettings: Decodable {
             settings.delay = delay
         }
 
-        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/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 {}
+}

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

@@ -0,0 +1,20 @@
+import Combine
+import SwiftUI
+
+extension GlucoseAlerts {
+    final class StateModel: BaseStateModel<Provider> {
+        @Published var units: GlucoseUnits = .mgdL
+        @Published var glucoseBadge = false
+
+        override func subscribe() {
+            units = settingsManager.settings.units
+            subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
+        }
+    }
+}
+
+extension GlucoseAlerts.StateModel: SettingsObserver {
+    func settingsDidChange(_: TrioSettings) {
+        units = settingsManager.settings.units
+    }
+}

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

@@ -0,0 +1,46 @@
+import SwiftUI
+
+struct AddGlucoseAlertSheet: View {
+    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 {
+                Section {
+                    ForEach(GlucoseAlertType.allCases) { type in
+                        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)
+                            }
+                        }
+                    }
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Add Alarm")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) { dismiss() }
+                }
+            }
+        }
+    }
+}

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

@@ -0,0 +1,16 @@
+import SwiftUI
+
+struct AlarmActiveSection: View {
+    @Binding var alarm: GlucoseAlert
+
+    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: $alarm.activeOption)
+        }.listRowBackground(Color.chart)
+    }
+}

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

@@ -0,0 +1,167 @@
+import AVFoundation
+import SwiftUI
+
+struct AlarmAudioSection: View {
+    @Binding var alarm: GlucoseAlert
+
+    @State private var showTonePicker = false
+
+    var body: some View {
+        Section(header: Text("Alert Sound")) {
+            Toggle("Play Sound", isOn: $alarm.playsSound)
+
+            if alarm.playsSound {
+                Button {
+                    showTonePicker = true
+                } label: {
+                    HStack {
+                        Text("Tone")
+                        Spacer()
+                        Text(AlarmSoundCatalog.displayName(for: alarm.soundFilename))
+                            .foregroundColor(.secondary)
+                        Image(systemName: "chevron.right")
+                            .foregroundColor(.secondary)
+                            .font(.footnote)
+                    }
+                }
+                .buttonStyle(.plain)
+                .sheet(isPresented: $showTonePicker) {
+                    TonePickerSheet(selected: $alarm.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 {
+            // best-effort preview — ignore
+        }
+    }
+
+    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
+    }
+}

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

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

@@ -0,0 +1,155 @@
+import SwiftUI
+
+struct GlucoseAlertEditorView: View {
+    @ObservedObject var store: GlucoseAlertsStore
+    let alertID: UUID
+    let isNew: Bool
+    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
+                }
+
+                AlarmActiveSection(alarm: $working)
+                AlarmAudioSection(alarm: $working)
+
+                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)
+            Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+            Toggle(
+                String(localized: "Override Silence & Do Not Disturb"),
+                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 Do Not Disturb."
+            ),
+            title: String(localized: "Glucose"),
+            range: 40 ... 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: 40 ... 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: 40 ... 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
+        )
+    }
+}

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

@@ -0,0 +1,307 @@
+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("Active")) {
+                        ForEach(enabledAlerts) { alarm in
+                            row(for: alarm)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+                if !disabledAlerts.isEmpty {
+                    Section(header: Text("Inactive")) {
+                        ForEach(disabledAlerts) { alarm in
+                            row(for: alarm).opacity(0.6)
+                        }
+                    }.listRowBackground(Color.chart)
+                }
+
+                Section(header: Text("Day and Night Window")) {
+                    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")
+                                    selectedVerboseHint =
+                                        AnyView(
+                                            VStack(alignment: .leading, spacing: 10) {
+                                                Text("Default: Day starts 06:00, Night starts 22:00.").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(
+                                                    "The Night window runs from 'Night Starts' back around to 'Day Starts' — so by default, Night covers 22:00 through 06:00 the next morning."
+                                                )
+
+                                                Text(
+                                                    "For example, you can create a Day only Low alarm at \(state.units == .mgdL ? "80" : 80.formattedAsMmolL), plus a second Night only Low alarm only at \(state.units == .mgdL ? "70" : 70.formattedAsMmolL), and be alerted at different glucose readings depending on time."
+                                                )
+                                            }
+                                        )
+
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: {
+                                    HStack {
+                                        Image(systemName: "questionmark.circle")
+                                    }
+                                }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom).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 { 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(perform: configureView)
+        }
+
+        private func handleSheetDismiss() {
+            guard let type = pendingNewType else { return }
+            pendingNewType = nil
+            DispatchQueue.main.async {
+                sheet = .editor(GlucoseAlert(type: type), isNew: true)
+            }
+        }
+
+        // MARK: - Day & Night time bindings
+
+        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) }
+            )
+        }
+
+        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)
+        }
+
+        // MARK: - Sorted lists
+
+        private var enabledAlerts: [GlucoseAlert] {
+            store.alerts
+                .filter(\.isEnabled)
+                .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")
+                default: return String(localized: "below")
+                }
+            }()
+            let threshold = "\(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 {
+            var icon = "speaker.fill"
+            var label = String(localized: "Sound on")
+            if !alarm.playsSound {
+                icon = "speaker.slash.fill"
+                label = String(localized: "Sound off")
+            } else if alarm.overridesSilenceAndDND {
+                icon = "speaker.wave.3.fill"
+                label = String(localized: "Override Silence & Focus")
+            }
+            return HStack(spacing: 4) {
+                Image(systemName: icon)
+                Text(label)
+            }
+            .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 - 43
Trio/Sources/Modules/GlucoseNotificationSettings/GlucoseNotificationSettingsStateModel.swift

@@ -1,43 +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
-
-        var units: GlucoseUnits = .mgdL
-
-        override func subscribe() {
-            let units = settingsManager.settings.units
-            self.units = units
-
-            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 - 285
Trio/Sources/Modules/GlucoseNotificationSettings/View/GlucoseNotificationSettingsRootView.swift

@@ -1,285 +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.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)
-        }
-    }
-}

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

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

@@ -731,8 +731,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

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

@@ -322,24 +322,20 @@ 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",
+                "Urgent Low Glucose",
+                "Low Glucose",
+                "Forecasted Low Glucose",
+                "High Glucose",
+                "Day & Night",
+                "Alert Sound",
+                "Override Silence",
                 "Show Glucose App Badge",
-                "Glucose Notifications",
-                "Add Glucose Source to Alarm",
-                "Low Glucose Alarm Limit",
-                "High Glucose Alarm Limit"
+                "Add Glucose Source to Alarm"
             ],
-            scrollTargetLabels: [
-                "Low Glucose Alarm Limit": "Glucose Notifications",
-                "High Glucose Alarm Limit": "Glucose Notifications"
-            ],
-            path: ["Notifications", "Trio Notifications"] // Glucose
+            path: ["Notifications", "Glucose Alarms"]
         ),
         SettingItem(
             title: "Live Activity",

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

@@ -76,8 +76,8 @@ 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)
 
                     if #available(iOS 16.2, *) {
                         Text("Live Activity").navigationLink(to: .liveActivitySettings, from: self)

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

@@ -853,37 +853,6 @@ extension SettingsExport {
                     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")

+ 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.")
                                                 }

+ 5 - 6
Trio/Sources/Router/Router.swift

@@ -50,14 +50,13 @@ final class BaseRouter: Router {
         screen.view(resolver: resolver).asAny()
     }
 
-    func allowNotify(_ message: MessageContent, _ settings: TrioSettings) -> Bool {
+    func allowNotify(_ message: MessageContent, _: TrioSettings) -> Bool {
         if message.type == .error { return true }
         if message.subtype == .glucose {
-            return (
-                message.type == .warning &&
-                    settings.glucoseNotificationsOption == GlucoseNotificationsOption.onlyAlarmLimits
-            ) ||
-                settings.glucoseNotificationsOption == GlucoseNotificationsOption.alwaysEveryCGM
+            // Glucose alarms are owned by `GlucoseAlertCoordinator` and routed
+            // through `TrioAlertManager`. Generic glucose-warning messages on
+            // the legacy bus no longer surface notifications.
+            return false
         }
         return true
     }

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

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

+ 0 - 77
Trio/Sources/Services/Alerts/ForecastedGlucoseAlertObserver.swift

@@ -1,77 +0,0 @@
-import Foundation
-import LoopKit
-import Swinject
-
-/// Listens to `DeterminationObserver` and issues `glucose.forecastedLow` when
-/// the blended +20-min prediction (see `ForecastedGlucoseEvaluator`) drops
-/// below the user's low-glucose threshold. Retracts once the forecast
-/// recovers above the threshold plus a small margin to avoid flap.
-final class ForecastedGlucoseAlertObserver: Injectable {
-    @Injected() private var broadcaster: Broadcaster!
-    @Injected() private var trioAlertManager: TrioAlertManager!
-    @Injected() private var settingsManager: SettingsManager!
-
-    private static let recoveryMarginMgDL: Decimal = 10
-    private static let alertIdentifier = "glucose.forecastedLow"
-
-    private let queue = DispatchQueue(label: "ForecastedGlucoseAlertObserver.queue")
-    private var isFiring = false
-
-    init(resolver: Resolver) {
-        injectServices(resolver)
-        broadcaster.register(DeterminationObserver.self, observer: self)
-    }
-
-    private var alertID: Alert.Identifier {
-        Alert.Identifier(
-            managerIdentifier: BaseTrioAlertManager.managerIdentifier,
-            alertIdentifier: Self.alertIdentifier
-        )
-    }
-}
-
-extension ForecastedGlucoseAlertObserver: DeterminationObserver {
-    func determinationDidUpdate(_ determination: Determination) {
-        guard let result = ForecastedGlucoseEvaluator.evaluate(determination: determination) else {
-            return
-        }
-        let threshold = settingsManager.settings.lowGlucose
-        let predicted = result.predictedGlucose
-        let units = settingsManager.settings.units
-
-        queue.async { [weak self] in
-            guard let self else { return }
-            let firing = self.isFiring
-
-            if predicted < threshold, !firing {
-                self.isFiring = true
-                let title = String(localized: "Predicted Low")
-                let body = String(
-                    format: String(
-                        localized: "Glucose forecasted to reach %1$@ within %2$d minutes (limit: %3$@)."
-                    ),
-                    predicted.formatted(withUnits: units),
-                    result.horizonMinutes,
-                    threshold.formatted(withUnits: units)
-                )
-                let content = Alert.Content(
-                    title: title,
-                    body: body,
-                    acknowledgeActionButtonLabel: String(localized: "OK")
-                )
-                let alert = Alert(
-                    identifier: self.alertID,
-                    foregroundContent: content,
-                    backgroundContent: content,
-                    trigger: .immediate,
-                    interruptionLevel: TrioAlertCategory.glucoseForecastedLow.interruptionLevel,
-                    sound: .sound(name: "trill.caf")
-                )
-                self.trioAlertManager.issueAlert(alert)
-            } else if firing, predicted >= threshold + Self.recoveryMarginMgDL {
-                self.isFiring = false
-                self.trioAlertManager.retractAlert(identifier: self.alertID)
-            }
-        }
-    }
-}

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

@@ -0,0 +1,289 @@
+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!
+
+    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.
+    private static let recoveryMarginMgDL: Decimal = 5
+
+    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)
+        glucoseStorage.updatePublisher
+            .receive(on: evaluationQueue)
+            .sink { [weak self] _ in self?.evaluateGlucoseAlarms() }
+            .store(in: &subscriptions)
+    }
+
+    // MARK: - Reading-based evaluation
+
+    private func evaluateGlucoseAlarms() {
+        let snapshot = alertsSnapshot
+        let configuration = configurationSnapshot
+        let now = Date()
+
+        guard let latest = fetchLatestReading() else { return }
+        let latestValue = Decimal(latest.glucose)
+
+        // 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 != .forecastedLow {
+            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.isEnabled, !isAlarmSnoozed(alarm, at: now),
+              isActive(alarm, at: now, configuration: configuration)
+        else {
+            retractIfFiring(alarm)
+            return
+        }
+
+        let breached: Bool
+        switch alarm.type {
+        case .low,
+             .urgentLow:
+            breached = latestMgDL <= alarm.thresholdMgDL
+        case .high:
+            breached = latestMgDL >= alarm.thresholdMgDL
+        case .forecastedLow:
+            return // handled via determinationDidUpdate
+        }
+
+        if breached {
+            fireIfNeeded(alarm, valueMgDL: latestMgDL)
+        } else if shouldRetract(alarm, latestMgDL: latestMgDL) {
+            retractIfFiring(alarm)
+        }
+    }
+
+    // MARK: - Forecast-based evaluation
+
+    private func evaluateForecast(_ determination: Determination) {
+        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.isEnabled, !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: - 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 shouldRetract(_ alarm: GlucoseAlert, latestMgDL: Decimal) -> Bool {
+        dispatchPrecondition(condition: .onQueue(evaluationQueue))
+        guard firingAlertIDs.contains(alarm.id) else { return false }
+        switch alarm.type {
+        case .forecastedLow,
+             .low,
+             .urgentLow:
+            return latestMgDL >= alarm.thresholdMgDL + Self.recoveryMarginMgDL
+        case .high:
+            return latestMgDL <= alarm.thresholdMgDL - Self.recoveryMarginMgDL
+        }
+    }
+
+    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"
+        }
+        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$@ at or below %2$@."),
+                valueString, limitString
+            )
+        case .forecastedLow:
+            return String(
+                format: String(localized: "Forecast +20 min %1$@, at or below limit %2$@."),
+                valueString, limitString
+            )
+        case .high:
+            return String(
+                format: String(localized: "Glucose %1$@ at or above %2$@."),
+                valueString, limitString
+            )
+        }
+    }
+
+    // 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)
+        }
+    }
+
+    private func fetchLatestReading() -> GlucoseStored? {
+        do {
+            let cutoff = Date().addingTimeInterval(-20 * 60)
+            let predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
+            let results = try CoreDataStack.shared.fetchEntities(
+                ofType: GlucoseStored.self,
+                onContext: coreDataContext,
+                predicate: predicate,
+                key: "date",
+                ascending: false,
+                fetchLimit: 1
+            ) as? [GlucoseStored]
+            return results?.first
+        } 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)
+        }
+    }
+}

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

@@ -0,0 +1,98 @@
+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) ?? []
+        alerts = loaded.isEmpty ? Self.defaultAlerts() : loaded
+        configuration = Self.decode(
+            GlucoseAlertConfiguration.self,
+            from: defaults,
+            key: configKey
+        ) ?? GlucoseAlertConfiguration()
+        bind()
+    }
+
+    /// Enabled Low + High pair, urgent-low and forecasted-low disabled by
+    /// default. User can enable, edit thresholds, or add more entries.
+    private static func defaultAlerts() -> [GlucoseAlert] {
+        var urgent = GlucoseAlert(type: .urgentLow)
+        urgent.isEnabled = false
+        var forecasted = GlucoseAlert(type: .forecastedLow)
+        forecasted.isEnabled = false
+        let low = GlucoseAlert(type: .low)
+        let high = GlucoseAlert(type: .high)
+        return [urgent, low, forecasted, high]
+    }
+
+    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
+    }
+
+    // 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)
+    }
+}

+ 3 - 1
Trio/Sources/Services/Alerts/TrioAlertManager.swift

@@ -78,7 +78,9 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
     private func playCriticalAudioFallbackIfNeeded(_ alert: Alert, muted: Bool) {
         guard alert.interruptionLevel == .critical, !muted else { return }
         guard case .immediate = alert.trigger else { return }
-        let soundName = alert.sound?.filename ?? "critical.caf"
+        // 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)

+ 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

+ 5 - 170
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -59,12 +59,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @Injected() private var router: Router!
     @Injected() private var trioAlertManager: TrioAlertManager!
 
-    @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
-
     @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()
@@ -260,6 +255,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
+    /// Maintains the 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 sendGlucoseNotification() async {
         do {
             addAppBadge(glucose: nil)
@@ -267,157 +266,14 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
             let glucoseObjects = 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
-                }
-            }
         } catch {
             debugPrint(
-                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to send glucose notification with error: \(error)"
+                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update glucose badge with error: \(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 {
@@ -443,7 +299,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @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()
 
@@ -509,26 +364,6 @@ 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 {

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