Bläddra i källkod

Alarm windows, per-variant toggle, not-looping pipeline, UN cleanup

* Day & Night windows pulled into their own module AlarmWindows.
  Removed the inline section from Glucose Alarms; both Glucose and
  Device Alarms link to it from a footer row. Single source of truth
  still lives on GlucoseAlertsStore.configuration.
* DeviceAlertSeverityConfig is now a flat list — multiple configs per
  severity allowed, each with its own ActiveOption window. Editor has
  isEnabled toggle (matches Glucose Alarms). Lookup picks the matching
  windowed variant; falls back to the .always baseline. If every
  variant in a tier is disabled, TrioAlertManager drops the alert.
* Add Variant flow: toolbar + → AddDeviceAlarmSheet picks severity →
  editor opens with next-unused window suggested.
* Not Looping rewired through TrioAlertManager. NotLoopingMonitor
  subscribes to lastLoopDateSubject; on every successful loop calls
  retractAlert then issues a fresh delayed (20m) alert. Categorized
  as .notLooping → Critical tier. Replaces the legacy direct-UN
  scheduleMissingLoopNotifiactions path.
* TrioAlertManager.retractAlert resets the throttler for the
  identifier so re-arming the same alarm isn't blocked by 5-min
  duplicate suppression.
* UserNotificationsManager cleanup: drop unused imports
  (AudioToolbox, LoopKit, UIKit), drop the dead `lifetime` member,
  drop the noLoopFirst/Second identifier cases + firstInterval /
  secondInterval, drop the algorithm-subtype branch in addRequest,
  collapse registerHandlers + registerSubscribers into one
  subscribeGlucoseUpdates, rename sendGlucoseNotification →
  updateGlucoseBadge to match what it actually does.
* GlucoseSourceKey → Models/GlucoseSourceKey.swift (was misplaced).
* BolusFailureObserver → APSManager.swift (where it's broadcast).
Deniz Cengiz 1 månad sedan
förälder
incheckning
9b70f4c25a
24 ändrade filer med 750 tillägg och 280 borttagningar
  1. 44 0
      Trio.xcodeproj/project.pbxproj
  2. 7 0
      Trio/Sources/APS/APSManager.swift
  3. 1 0
      Trio/Sources/Application/TrioApp.swift
  4. 2 0
      Trio/Sources/Assemblies/ServiceAssembly.swift
  5. 1 1
      Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift
  6. 30 12
      Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverityConfig.swift
  7. 4 0
      Trio/Sources/Models/GlucoseAlerts/PumpAlertCategory.swift
  8. 10 0
      Trio/Sources/Models/GlucoseSourceKey.swift
  9. 7 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsDataFlow.swift
  10. 5 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsProvider.swift
  11. 18 0
      Trio/Sources/Modules/AlarmWindows/AlarmWindowsStateModel.swift
  12. 114 0
      Trio/Sources/Modules/AlarmWindows/View/AlarmWindowsRootView.swift
  13. 65 0
      Trio/Sources/Modules/DeviceAlarms/View/AddDeviceAlarmSheet.swift
  14. 89 27
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift
  15. 134 37
      Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift
  16. 4 86
      Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift
  17. 10 1
      Trio/Sources/Modules/Settings/SettingItems.swift
  18. 3 0
      Trio/Sources/Modules/Settings/View/Subviews/NotificationsView.swift
  19. 3 0
      Trio/Sources/Router/Screen.swift
  20. 92 11
      Trio/Sources/Services/Alerts/DeviceAlertsStore.swift
  21. 62 0
      Trio/Sources/Services/Alerts/NotLoopingMonitor.swift
  22. 7 0
      Trio/Sources/Services/Alerts/TrioAlertCategory.swift
  23. 16 3
      Trio/Sources/Services/Alerts/TrioAlertManager.swift
  24. 22 102
      Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

+ 44 - 0
Trio.xcodeproj/project.pbxproj

@@ -211,6 +211,13 @@
 		BD11798D2F4E22C100F90001 /* DeviceAlarmsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11798E2F4E22C100F90001 /* DeviceAlarmsStateModel.swift */; };
 		BD11798F2F4E22C100F90001 /* DeviceAlarmsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179902F4E22C100F90001 /* DeviceAlarmsRootView.swift */; };
 		BD1179912F4E22C100F90001 /* DeviceAlarmEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179922F4E22C100F90001 /* DeviceAlarmEditorView.swift */; };
+		BD1179932F4E22C100F90001 /* AddDeviceAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179942F4E22C100F90001 /* AddDeviceAlarmSheet.swift */; };
+		BD1179A02F4E22C100F90001 /* AlarmWindowsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179A12F4E22C100F90001 /* AlarmWindowsDataFlow.swift */; };
+		BD1179A22F4E22C100F90001 /* AlarmWindowsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179A32F4E22C100F90001 /* AlarmWindowsProvider.swift */; };
+		BD1179A42F4E22C100F90001 /* AlarmWindowsStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179A52F4E22C100F90001 /* AlarmWindowsStateModel.swift */; };
+		BD1179A62F4E22C100F90001 /* AlarmWindowsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179A72F4E22C100F90001 /* AlarmWindowsRootView.swift */; };
+		BD1179AA2F4E22C100F90001 /* NotLoopingMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179AB2F4E22C100F90001 /* NotLoopingMonitor.swift */; };
+		BD1179AC2F4E22C100F90001 /* GlucoseSourceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179AD2F4E22C100F90001 /* GlucoseSourceKey.swift */; };
 		38E989DD25F5021400C0CED0 /* PumpStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E989DC25F5021400C0CED0 /* PumpStatus.swift */; };
 		38E98A2325F52C9300C0CED0 /* Signpost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1B25F52C9300C0CED0 /* Signpost.swift */; };
 		38E98A2425F52C9300C0CED0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E98A1C25F52C9300C0CED0 /* Logger.swift */; };
@@ -1131,6 +1138,13 @@
 		BD11798E2F4E22C100F90001 /* DeviceAlarmsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsStateModel.swift; sourceTree = "<group>"; };
 		BD1179902F4E22C100F90001 /* DeviceAlarmsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmsRootView.swift; sourceTree = "<group>"; };
 		BD1179922F4E22C100F90001 /* DeviceAlarmEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlarmEditorView.swift; sourceTree = "<group>"; };
+		BD1179942F4E22C100F90001 /* AddDeviceAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddDeviceAlarmSheet.swift; sourceTree = "<group>"; };
+		BD1179A12F4E22C100F90001 /* AlarmWindowsDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowsDataFlow.swift; sourceTree = "<group>"; };
+		BD1179A32F4E22C100F90001 /* AlarmWindowsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowsProvider.swift; sourceTree = "<group>"; };
+		BD1179A52F4E22C100F90001 /* AlarmWindowsStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowsStateModel.swift; sourceTree = "<group>"; };
+		BD1179A72F4E22C100F90001 /* AlarmWindowsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmWindowsRootView.swift; sourceTree = "<group>"; };
+		BD1179AB2F4E22C100F90001 /* NotLoopingMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotLoopingMonitor.swift; sourceTree = "<group>"; };
+		BD1179AD2F4E22C100F90001 /* GlucoseSourceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSourceKey.swift; sourceTree = "<group>"; };
 		38E989DC25F5021400C0CED0 /* PumpStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatus.swift; sourceTree = "<group>"; };
 		38E98A1B25F52C9300C0CED0 /* Signpost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Signpost.swift; sourceTree = "<group>"; };
 		38E98A1C25F52C9300C0CED0 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
@@ -2057,6 +2071,7 @@
 				DD17454C2C55CA0200211FAC /* GeneralSettings */,
 				BD1179602F4E22C100F90001 /* GlucoseAlerts */,
 				BD11799A2F4E22C100F90001 /* DeviceAlarms */,
+				BD1179A82F4E22C100F90001 /* AlarmWindows */,
 				F90692CD274B99850037068D /* HealthKit */,
 				3811DE2725C9D49500A708ED /* Home */,
 				19E1F7E629D0828B005C8D20 /* IconConfig */,
@@ -2226,6 +2241,7 @@
 				BD11795D2F4E22C100F90001 /* GlucoseAlertCoordinator.swift */,
 				BD1179592F4E22C100F90001 /* GlucoseAlertsStore.swift */,
 				BD1179882F4E22C100F90001 /* DeviceAlertsStore.swift */,
+				BD1179AB2F4E22C100F90001 /* NotLoopingMonitor.swift */,
 				BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */,
 				BD1179212F4E22C100F90001 /* TrioAlertManager.swift */,
 				BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */,
@@ -2610,6 +2626,7 @@
 				49090A8C2E9FE8D200D0F5DB /* GarminWatchSettings.swift */,
 				DD4FFF322D458EE600B6CFF9 /* GarminWatchState.swift */,
 				DD940BA92CA7585D000830A5 /* GlucoseColorScheme.swift */,
+				BD1179AD2F4E22C100F90001 /* GlucoseSourceKey.swift */,
 				E0D4F80427513ECF00BDF1FE /* HealthKitSample.swift */,
 				1967DFBD29D052C200759F30 /* Icons.swift */,
 				382C133625F13A1E00715CE1 /* InsulinSensitivities.swift */,
@@ -4017,6 +4034,25 @@
 			path = GlucoseAlerts;
 			sourceTree = "<group>";
 		};
+		BD1179A82F4E22C100F90001 /* AlarmWindows */ = {
+			isa = PBXGroup;
+			children = (
+				BD1179A12F4E22C100F90001 /* AlarmWindowsDataFlow.swift */,
+				BD1179A32F4E22C100F90001 /* AlarmWindowsProvider.swift */,
+				BD1179A52F4E22C100F90001 /* AlarmWindowsStateModel.swift */,
+				BD1179A92F4E22C100F90001 /* View */,
+			);
+			path = AlarmWindows;
+			sourceTree = "<group>";
+		};
+		BD1179A92F4E22C100F90001 /* View */ = {
+			isa = PBXGroup;
+			children = (
+				BD1179A72F4E22C100F90001 /* AlarmWindowsRootView.swift */,
+			);
+			path = View;
+			sourceTree = "<group>";
+		};
 		BD11799A2F4E22C100F90001 /* DeviceAlarms */ = {
 			isa = PBXGroup;
 			children = (
@@ -4031,6 +4067,7 @@
 		BD11799B2F4E22C100F90001 /* View */ = {
 			isa = PBXGroup;
 			children = (
+				BD1179942F4E22C100F90001 /* AddDeviceAlarmSheet.swift */,
 				BD1179922F4E22C100F90001 /* DeviceAlarmEditorView.swift */,
 				BD1179902F4E22C100F90001 /* DeviceAlarmsRootView.swift */,
 			);
@@ -4749,6 +4786,13 @@
 				BD11798D2F4E22C100F90001 /* DeviceAlarmsStateModel.swift in Sources */,
 				BD11798F2F4E22C100F90001 /* DeviceAlarmsRootView.swift in Sources */,
 				BD1179912F4E22C100F90001 /* DeviceAlarmEditorView.swift in Sources */,
+				BD1179932F4E22C100F90001 /* AddDeviceAlarmSheet.swift in Sources */,
+				BD1179A02F4E22C100F90001 /* AlarmWindowsDataFlow.swift in Sources */,
+				BD1179A22F4E22C100F90001 /* AlarmWindowsProvider.swift in Sources */,
+				BD1179A42F4E22C100F90001 /* AlarmWindowsStateModel.swift in Sources */,
+				BD1179A62F4E22C100F90001 /* AlarmWindowsRootView.swift in Sources */,
+				BD1179AA2F4E22C100F90001 /* NotLoopingMonitor.swift in Sources */,
+				BD1179AC2F4E22C100F90001 /* GlucoseSourceKey.swift in Sources */,
 				DD3F1F902D9E153F00DCE7B3 /* NightscoutImportStepView.swift in Sources */,
 				CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */,
 				DD1745372C55B74200211FAC /* AlgorithmSettings.swift in Sources */,

+ 7 - 0
Trio/Sources/APS/APSManager.swift

@@ -41,6 +41,13 @@ protocol APSManager {
     var iobFileDidUpdate: PassthroughSubject<Void, Never> { get }
 }
 
+/// Notified after a bolus-related failure so observing UI (e.g. the
+/// treatment screen's bolus state) can clean up state. Broadcast by
+/// `APSManager` from `enactBolus` / `cancelBolus` error paths.
+protocol BolusFailureObserver {
+    func bolusDidFail()
+}
+
 enum APSError: LocalizedError {
     case pumpError(Error)
     case invalidPumpState(message: String)

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

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

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

@@ -21,6 +21,8 @@ final class ServiceAssembly: Assembly {
             .inObjectScope(.container)
         container.register(GlucoseAlertCoordinator.self) { r in GlucoseAlertCoordinator(resolver: r) }
             .inObjectScope(.container)
+        container.register(NotLoopingMonitor.self) { r in NotLoopingMonitor(resolver: r) }
+            .inObjectScope(.container)
         container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) }
         container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) }
         container.register(BolusCalculationManager.self) { r in BaseBolusCalculationManager(resolver: r) }

+ 1 - 1
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverity.swift

@@ -29,7 +29,7 @@ enum DeviceAlertSeverity: String, Codable, CaseIterable, Identifiable {
         case .timeSensitive:
             return String(localized: "Pierces banner suppression but obeys Silence & Focus Mode.")
         case .normal:
-            return String(localized: "Default notification banner. Suppressed by Silence and DND.")
+            return String(localized: "Default notification banner. Suppressed by Silence and Focus Mode.")
         }
     }
 

+ 30 - 12
Trio/Sources/Models/GlucoseAlerts/DeviceAlertSeverityConfig.swift

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

+ 4 - 0
Trio/Sources/Models/GlucoseAlerts/PumpAlertCategory.swift

@@ -14,6 +14,7 @@ enum PumpAlertCategory: String, Codable, CaseIterable, Identifiable {
     case bolusFailed
     case deliveryUncertain
     case manualTempBasalActive
+    case notLooping
     case sensorFailure
     case deviceExpirationReminder
     case deviceExpired
@@ -34,6 +35,7 @@ enum PumpAlertCategory: String, Codable, CaseIterable, Identifiable {
         case .bolusFailed: return String(localized: "Bolus Failed")
         case .deliveryUncertain: return String(localized: "Delivery Uncertain")
         case .manualTempBasalActive: return String(localized: "Manual Temp Basal Active")
+        case .notLooping: return String(localized: "Loop Not Running")
         case .sensorFailure: return String(localized: "Sensor Failure")
         case .deviceExpirationReminder: return String(localized: "Device Expiration Reminder")
         case .deviceExpired: return String(localized: "Device Expired")
@@ -49,6 +51,7 @@ enum PumpAlertCategory: String, Codable, CaseIterable, Identifiable {
         case .batteryEmpty,
              .deliveryUncertain,
              .hardwareFault,
+             .notLooping,
              .occlusion,
              .reservoirEmpty:
             return .critical
@@ -78,6 +81,7 @@ enum PumpAlertCategory: String, Codable, CaseIterable, Identifiable {
         case .bolusFailed: self = .bolusFailed
         case .deliveryUncertain: self = .deliveryUncertain
         case .manualTempBasalActive: self = .manualTempBasalActive
+        case .notLooping: self = .notLooping
         case .sensorFailure: self = .sensorFailure
         case .deviceExpirationReminder: self = .deviceExpirationReminder
         case .deviceExpired: self = .deviceExpired

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,114 @@
+import SwiftUI
+import Swinject
+
+extension AlarmWindows {
+    struct RootView: BaseView {
+        let resolver: Resolver
+        @StateObject var state = StateModel()
+        @StateObject private var store = GlucoseAlertsStore.shared
+
+        @State private var shouldDisplayHint: Bool = false
+        @State private var hintDetent = PresentationDetent.large
+        @State private var selectedVerboseHint: AnyView?
+        @State private var hintLabel: String?
+
+        @Environment(\.colorScheme) var colorScheme
+        @Environment(AppState.self) var appState
+
+        private var dayStart: Binding<Date> {
+            Binding(
+                get: { Self.dateFromTimeOfDay(store.configuration.dayStart) },
+                set: { store.configuration.dayStart = Self.timeOfDay(from: $0) }
+            )
+        }
+
+        private var nightStart: Binding<Date> {
+            Binding(
+                get: { Self.dateFromTimeOfDay(store.configuration.nightStart) },
+                set: { store.configuration.nightStart = Self.timeOfDay(from: $0) }
+            )
+        }
+
+        var body: some View {
+            List {
+                Section(header: Text("Window Boundaries")) {
+                    VStack {
+                        DatePicker(
+                            selection: dayStart,
+                            displayedComponents: .hourAndMinute
+                        ) {
+                            HStack {
+                                Image(systemName: "sun.max.fill").foregroundStyle(.orange)
+                                Text("Day Starts")
+                            }
+                        }
+                        .padding(.top)
+
+                        DatePicker(
+                            selection: nightStart,
+                            displayedComponents: .hourAndMinute
+                        ) {
+                            HStack {
+                                Image(systemName: "moon.stars.fill").foregroundStyle(.indigo)
+                                Text("Night Starts")
+                            }
+                        }
+
+                        HStack(alignment: .center) {
+                            Text("Decides when each alarm's Day or Night setting applies.")
+                                .lineLimit(nil)
+                                .font(.footnote)
+                                .foregroundColor(.secondary)
+
+                            Spacer()
+                            Button(
+                                action: {
+                                    hintLabel = String(localized: "Day and Night Window")
+                                    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(
+                                                "These windows are shared between Glucose Alarms and Device Alarms."
+                                            )
+                                        }
+                                    )
+                                    shouldDisplayHint.toggle()
+                                },
+                                label: { Image(systemName: "questionmark.circle") }
+                            ).buttonStyle(BorderlessButtonStyle())
+                        }.padding(.top)
+                    }.padding(.bottom)
+                }.listRowBackground(Color.chart)
+            }
+            .scrollContentBackground(.hidden)
+            .background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle("Day & Night Windows")
+            .navigationBarTitleDisplayMode(.inline)
+            .sheet(isPresented: $shouldDisplayHint) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHint,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help")
+                )
+            }
+            .onAppear(perform: configureView)
+        }
+
+        private static func dateFromTimeOfDay(_ time: TimeOfDay) -> Date {
+            Calendar.current.date(bySettingHour: time.hour, minute: time.minute, second: 0, of: Date()) ?? Date()
+        }
+
+        private static func timeOfDay(from date: Date) -> TimeOfDay {
+            let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
+            return TimeOfDay(hour: comps.hour ?? 0, minute: comps.minute ?? 0)
+        }
+    }
+}

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

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

+ 89 - 27
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmEditorView.swift

@@ -1,46 +1,108 @@
 import SwiftUI
 
 struct DeviceAlarmEditorView: View {
-    let severity: DeviceAlertSeverity
     @ObservedObject var store: DeviceAlertsStore
+    let configID: UUID
+    let isNew: Bool
+    var onDone: () -> Void
+    var onCancel: () -> Void
 
+    @Environment(\.dismiss) private var dismiss
     @Environment(\.colorScheme) private var colorScheme
     @Environment(AppState.self) private var appState
     @State private var working: DeviceAlertSeverityConfig
 
-    init(severity: DeviceAlertSeverity, store: DeviceAlertsStore) {
-        self.severity = severity
+    init(
+        store: DeviceAlertsStore,
+        initial: DeviceAlertSeverityConfig,
+        isNew: Bool,
+        onDone: @escaping () -> Void,
+        onCancel: @escaping () -> Void = {}
+    ) {
         self.store = store
-        _working = State(initialValue: store.config(for: severity) ?? DeviceAlertSeverityConfig(severity: severity))
+        configID = initial.id
+        self.isNew = isNew
+        self.onDone = onDone
+        self.onCancel = onCancel
+        _working = State(initialValue: initial)
     }
 
     var body: some View {
-        Form {
-            Section(header: Text("Behavior"), footer: Text(severity.blurb)) {
-                Text(severity.displayName).font(.headline)
-                Toggle(
-                    String(localized: "Override Silence & Focus Mode"),
-                    isOn: $working.overridesSilenceAndDND
+        NavigationStack {
+            Form {
+                Section(
+                    header: Text("Behavior"),
+                    footer: Text(working.severity.blurb)
+                ) {
+                    HStack {
+                        Text(working.severity.displayName).font(.headline)
+                        Spacer()
+                        Text(activeLabel)
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
+                    Toggle(String(localized: "Enabled"), isOn: $working.isEnabled)
+                    Toggle(
+                        String(localized: "Override Silence & Focus Mode"),
+                        isOn: $working.overridesSilenceAndDND
+                    )
+                }.listRowBackground(Color.chart)
+
+                AlarmActiveSection(activeOption: $working.activeOption)
+                AlarmAudioSection(
+                    playsSound: $working.playsSound,
+                    soundFilename: $working.soundFilename
                 )
+
+                Section(header: Text("Applies To")) {
+                    ForEach(PumpAlertCategory.allCases.filter { $0.defaultSeverity == working.severity }) { category in
+                        Text(category.displayName)
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                    }
+                }.listRowBackground(Color.chart)
+
+                if !isNew, store.canDelete(working) {
+                    Section {
+                        Button(role: .destructive) {
+                            store.remove(working)
+                            dismiss()
+                        } label: {
+                            Text("Delete Variant")
+                        }
+                    }.listRowBackground(Color.chart)
+                }
             }
-            .listRowBackground(Color.chart)
-
-            AlarmAudioSection(
-                playsSound: $working.playsSound,
-                soundFilename: $working.soundFilename
-            )
-
-            Section(header: Text("Applies To")) {
-                ForEach(PumpAlertCategory.allCases.filter { $0.defaultSeverity == severity }) { category in
-                    Text(category.displayName)
-                        .font(.footnote)
-                        .foregroundColor(.secondary)
+            .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
+            .navigationTitle(working.severity.displayName)
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .confirmationAction) {
+                    Button(isNew ? String(localized: "Add") : String(localized: "Done")) {
+                        if isNew {
+                            store.add(working)
+                        } else {
+                            store.update(working)
+                        }
+                        onDone()
+                        dismiss()
+                    }
+                }
+                ToolbarItem(placement: .cancellationAction) {
+                    Button(String(localized: "Cancel")) {
+                        onCancel()
+                        dismiss()
+                    }
                 }
-            }.listRowBackground(Color.chart)
+            }
+        }
+    }
+
+    private var activeLabel: String {
+        switch working.activeOption {
+        case .always: return String(localized: "Day & Night")
+        case .day: return String(localized: "Day only")
+        case .night: return String(localized: "Night only")
         }
-        .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
-        .navigationTitle(severity.displayName)
-        .navigationBarTitleDisplayMode(.inline)
-        .onChange(of: working) { _, new in store.update(new) }
     }
 }

+ 134 - 37
Trio/Sources/Modules/DeviceAlarms/View/DeviceAlarmsRootView.swift

@@ -1,69 +1,149 @@
 import SwiftUI
 import Swinject
 
+private enum DeviceAlarmSheet: Identifiable {
+    case picker
+    case editor(DeviceAlertSeverityConfig, isNew: Bool)
+
+    var id: String {
+        switch self {
+        case .picker: return "picker"
+        case let .editor(config, _): return config.id.uuidString
+        }
+    }
+}
+
 extension DeviceAlarms {
     struct RootView: BaseView {
         let resolver: Resolver
         @StateObject var state = StateModel()
         @StateObject private var store = DeviceAlertsStore.shared
 
+        @State private var sheet: DeviceAlarmSheet?
+        @State private var pendingNewSeverity: DeviceAlertSeverity?
+
         @Environment(\.colorScheme) var colorScheme
         @Environment(AppState.self) var appState
 
         var body: some View {
             List {
-                Section(
-                    header: Text("Severity Tiers"),
-                    footer: Text(
-                        "Three tiers govern every pump and device alarm. Each pump alarm maps to one tier based on how hazardous it is — you configure the tier, not each alarm. See the bottom of this screen for which category lives in which tier."
-                    )
-                ) {
-                    ForEach(DeviceAlertSeverity.allCases) { severity in
-                        NavigationLink {
-                            DeviceAlarmEditorView(severity: severity, store: store)
-                        } label: {
-                            row(for: severity)
+                ForEach(DeviceAlertSeverity.allCases) { severity in
+                    Section(
+                        header: header(for: severity),
+                        footer: footer(for: severity)
+                    ) {
+                        ForEach(store.configs(in: severity)) { config in
+                            row(for: config)
+                                .opacity(config.isEnabled ? 1 : 0.5)
                         }
-                    }
+                    }.listRowBackground(Color.chart)
+                }
+
+                Section {
+                    Text("Day & Night Windows")
+                        .navigationLink(to: .alarmWindows, from: self)
                 }.listRowBackground(Color.chart)
             }
             .scrollContentBackground(.hidden)
             .background(appState.trioBackgroundColor(for: colorScheme))
             .navigationTitle("Device Alarms")
             .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .primaryAction) {
+                    Button { sheet = .picker } label: { Image(systemName: "plus") }
+                }
+            }
+            .sheet(item: $sheet, onDismiss: handleSheetDismiss) { which in
+                switch which {
+                case .picker:
+                    AddDeviceAlarmSheet { severity in
+                        pendingNewSeverity = severity
+                        sheet = nil
+                    }
+                case let .editor(config, isNew):
+                    DeviceAlarmEditorView(
+                        store: store,
+                        initial: config,
+                        isNew: isNew,
+                        onDone: { sheet = nil },
+                        onCancel: { sheet = nil }
+                    )
+                }
+            }
             .onAppear(perform: configureView)
         }
 
-        @ViewBuilder private func row(for severity: DeviceAlertSeverity) -> some View {
-            let config = store.config(for: severity)
-            HStack(spacing: 12) {
+        private func handleSheetDismiss() {
+            guard let severity = pendingNewSeverity else { return }
+            pendingNewSeverity = nil
+            DispatchQueue.main.async {
+                let new = DeviceAlertSeverityConfig(
+                    severity: severity,
+                    activeOption: nextAvailableOption(for: severity)
+                )
+                sheet = .editor(new, isNew: true)
+            }
+        }
+
+        // MARK: - Section header / footer
+
+        private func header(for severity: DeviceAlertSeverity) -> some View {
+            HStack {
                 Image(systemName: severityIcon(for: severity))
                     .foregroundStyle(severityTint(for: severity))
-                    .font(.title3)
-                VStack(alignment: .leading, spacing: 2) {
-                    Text(severity.displayName)
-                        .foregroundColor(.primary)
-                    soundSummary(for: config)
-                }
-                Spacer()
-                Image(systemName: "chevron.right")
-                    .font(.footnote)
-                    .foregroundColor(.secondary)
+                Text(severity.displayName)
             }
         }
 
-        private func soundSummary(for config: DeviceAlertSeverityConfig?) -> some View {
-            var icon = "speaker.fill"
-            var label = String(localized: "Sound on")
-            if let config {
-                if !config.playsSound {
-                    icon = "speaker.slash.fill"
-                    label = String(localized: "Sound off")
-                } else if config.overridesSilenceAndDND {
-                    icon = "speaker.wave.3.fill"
-                    label = String(localized: "Overrides Silence & DND")
+        private func footer(for severity: DeviceAlertSeverity) -> some View {
+            Text(severity.blurb)
+        }
+
+        // MARK: - Row
+
+        @ViewBuilder private func row(for config: DeviceAlertSeverityConfig) -> some View {
+            Button {
+                sheet = .editor(config, isNew: false)
+            } label: {
+                HStack(spacing: 12) {
+                    AlarmWindowIcon(option: config.activeOption)
+                        .font(.title3)
+                    VStack(alignment: .leading, spacing: 2) {
+                        Text(windowLabel(for: config.activeOption))
+                            .foregroundColor(.primary)
+                        soundSummary(for: config)
+                    }
+                    Spacer()
+                    Image(systemName: "chevron.right")
+                        .font(.footnote)
+                        .foregroundColor(.secondary)
+                }
+            }
+            .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+                if store.canDelete(config) {
+                    Button(role: .destructive) {
+                        store.remove(config)
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
                 }
             }
+        }
+
+        private func soundSummary(for config: DeviceAlertSeverityConfig) -> some View {
+            let icon: String
+            let label: String
+            if !config.playsSound {
+                icon = "speaker.slash.fill"
+                label = String(localized: "Sound off")
+            } else if config.overridesSilenceAndDND {
+                icon = "speaker.wave.3.fill"
+                label =
+                    "\(AlarmSoundCatalog.displayName(for: config.soundFilename)) • \(String(localized: "Overrides Silence & Focus Mode"))"
+            } else {
+                icon = "speaker.fill"
+                label = AlarmSoundCatalog.displayName(for: config.soundFilename)
+            }
             return HStack(spacing: 4) {
                 Image(systemName: icon)
                 Text(label)
@@ -72,6 +152,16 @@ extension DeviceAlarms {
             .foregroundColor(.secondary)
         }
 
+        // MARK: - Helpers
+
+        private func windowLabel(for option: ActiveOption) -> String {
+            switch option {
+            case .always: return String(localized: "Day & Night")
+            case .day: return String(localized: "Day only")
+            case .night: return String(localized: "Night only")
+            }
+        }
+
         private func severityIcon(for severity: DeviceAlertSeverity) -> String {
             switch severity {
             case .critical: return "exclamationmark.triangle.fill"
@@ -88,8 +178,15 @@ extension DeviceAlarms {
             }
         }
 
-        private func categories(for severity: DeviceAlertSeverity) -> [PumpAlertCategory] {
-            PumpAlertCategory.allCases.filter { $0.defaultSeverity == severity }
+        /// Suggest an `ActiveOption` not yet used in this severity tier so the
+        /// editor opens on a meaningful new variant instead of duplicating
+        /// the existing `.always` row.
+        private func nextAvailableOption(for severity: DeviceAlertSeverity) -> ActiveOption {
+            let used = Set(store.configs(in: severity).map(\.activeOption))
+            for candidate in [ActiveOption.day, .night, .always] where !used.contains(candidate) {
+                return candidate
+            }
+            return .day
         }
     }
 }

+ 4 - 86
Trio/Sources/Modules/GlucoseAlerts/View/GlucoseAlertsRootView.swift

@@ -51,67 +51,10 @@ extension GlucoseAlerts {
                     }.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)
-                }
+                Section {
+                    Text("Day & Night Windows")
+                        .navigationLink(to: .alarmWindows, from: self)
+                }.listRowBackground(Color.chart)
 
                 SettingInputSection(
                     decimalValue: $decimalPlaceholder,
@@ -184,31 +127,6 @@ extension GlucoseAlerts {
             }
         }
 
-        // 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] {

+ 10 - 1
Trio/Sources/Modules/Settings/SettingItems.swift

@@ -345,14 +345,23 @@ enum SettingItems {
                 "Normal",
                 "Occlusion",
                 "Pump Fault",
+                "Hardware Fault",
                 "Reservoir",
                 "Battery",
                 "Bolus Failed",
-                "Pod"
+                "Pod",
+                "Sensor Failure",
+                "Device Expired"
             ],
             path: ["Notifications", "Device Alarms"]
         ),
         SettingItem(
+            title: "Day & Night Windows",
+            view: .alarmWindows,
+            searchContents: ["Day Starts", "Night Starts", "Day & Night"],
+            path: ["Notifications", "Day & Night Windows"]
+        ),
+        SettingItem(
             title: "Live Activity",
             view: .liveActivitySettings,
             searchContents: [

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

@@ -82,6 +82,9 @@ struct NotificationsView: BaseView {
                     Text("Device Alarms")
                         .navigationLink(to: .deviceAlarms, from: self)
 
+                    Text("Day & Night Windows")
+                        .navigationLink(to: .alarmWindows, from: self)
+
                     if #available(iOS 16.2, *) {
                         Text("Live Activity").navigationLink(to: .liveActivitySettings, from: self)
                     }

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

@@ -22,6 +22,7 @@ enum Screen: Identifiable, Hashable {
     case healthkit
     case glucoseAlerts
     case deviceAlarms
+    case alarmWindows
     case mealSettings
     case iconConfig
     case overrideConfig
@@ -110,6 +111,8 @@ extension Screen {
             GlucoseAlerts.RootView(resolver: resolver)
         case .deviceAlarms:
             DeviceAlarms.RootView(resolver: resolver)
+        case .alarmWindows:
+            AlarmWindows.RootView(resolver: resolver)
         case .mealSettings:
             MealSettings.RootView(resolver: resolver)
         case .iconConfig:

+ 92 - 11
Trio/Sources/Services/Alerts/DeviceAlertsStore.swift

@@ -1,9 +1,12 @@
 import Combine
 import Foundation
 
-/// Persists three `DeviceAlertSeverityConfig` rows (Critical /
-/// Time-Sensitive / Normal) to `UserDefaults`. Every pump / device alarm
-/// category maps to one of these tiers via `PumpAlertCategory.defaultSeverity`.
+/// Persists a flat list of `[DeviceAlertSeverityConfig]` to `UserDefaults`.
+/// Multiple configs per severity tier are allowed — each with its own
+/// `activeOption` so users can vary behavior between day and night.
+///
+/// Seeds three default configs (one per tier, all `activeOption: .always`)
+/// on first launch so every severity has a baseline that always matches.
 final class DeviceAlertsStore: ObservableObject {
     static let shared = DeviceAlertsStore()
 
@@ -21,11 +24,16 @@ final class DeviceAlertsStore: ObservableObject {
         self.defaults = defaults
         self.configsKey = configsKey
         let loaded = Self.decode([DeviceAlertSeverityConfig].self, from: defaults, key: configsKey) ?? []
-        var bySeverity = Dictionary(uniqueKeysWithValues: loaded.map { ($0.severity, $0) })
-        for severity in DeviceAlertSeverity.allCases where bySeverity[severity] == nil {
-            bySeverity[severity] = DeviceAlertSeverityConfig(severity: severity)
+        // Guarantee one default `.always` per severity on first load. Any
+        // missing severity gets a fresh seed so the lookup always finds a
+        // baseline match.
+        var seeded = loaded
+        for severity in DeviceAlertSeverity.allCases
+            where !seeded.contains(where: { $0.severity == severity && $0.activeOption == .always })
+        {
+            seeded.append(DeviceAlertSeverityConfig(severity: severity, activeOption: .always))
         }
-        configs = DeviceAlertSeverity.allCases.compactMap { bySeverity[$0] }
+        configs = Self.sorted(seeded)
         bind()
     }
 
@@ -37,16 +45,89 @@ final class DeviceAlertsStore: ObservableObject {
             .store(in: &subscriptions)
     }
 
-    func config(for severity: DeviceAlertSeverity) -> DeviceAlertSeverityConfig? {
-        configs.first { $0.severity == severity }
+    // MARK: - Lookup
+
+    /// Find the active config for a severity at the given moment. Considers
+    /// only enabled variants; picks the one whose `activeOption` matches the
+    /// current day/night window, falling back to the `.always` baseline.
+    /// Returns nil if every variant in this severity is disabled — caller
+    /// should drop the alarm in that case (user explicitly opted out).
+    func config(
+        for severity: DeviceAlertSeverity,
+        at _: Date,
+        isNight: Bool
+    ) -> DeviceAlertSeverityConfig? {
+        let matching = configs.filter { $0.severity == severity && $0.isEnabled }
+        let windowMatch = matching.first { config in
+            switch config.activeOption {
+            case .always: return false // .always is the fallback, prefer specific match
+            case .day: return !isNight
+            case .night: return isNight
+            }
+        }
+        if let windowMatch { return windowMatch }
+        return matching.first { $0.activeOption == .always } ?? matching.first
+    }
+
+    /// All configs in a single severity tier, sorted by `activeOption`
+    /// (Day & Night, Day only, Night only).
+    func configs(in severity: DeviceAlertSeverity) -> [DeviceAlertSeverityConfig] {
+        configs.filter { $0.severity == severity }
+    }
+
+    // MARK: - Mutators
+
+    func add(_ config: DeviceAlertSeverityConfig) {
+        configs.append(config)
+        configs = Self.sorted(configs)
     }
 
     func update(_ config: DeviceAlertSeverityConfig) {
-        guard let index = configs.firstIndex(where: { $0.severity == config.severity }) else { return }
+        guard let index = configs.firstIndex(where: { $0.id == config.id }) else { return }
         configs[index] = config
+        configs = Self.sorted(configs)
+    }
+
+    func remove(_ config: DeviceAlertSeverityConfig) {
+        guard canDelete(config) else { return }
+        configs.removeAll { $0.id == config.id }
+    }
+
+    /// At least one `.always` config per severity must remain so every alarm
+    /// has a baseline to fall back to. Other variants (.day / .night) can be
+    /// freely removed.
+    func canDelete(_ config: DeviceAlertSeverityConfig) -> Bool {
+        guard config.activeOption == .always else { return true }
+        let alwaysCount = configs.filter { $0.severity == config.severity && $0.activeOption == .always }.count
+        return alwaysCount > 1
+    }
+
+    // MARK: - Sorting + Codable helpers
+
+    private static func sorted(_ list: [DeviceAlertSeverityConfig]) -> [DeviceAlertSeverityConfig] {
+        list.sorted { lhs, rhs in
+            if lhs.severity != rhs.severity {
+                return severityRank(lhs.severity) < severityRank(rhs.severity)
+            }
+            return activeRank(lhs.activeOption) < activeRank(rhs.activeOption)
+        }
     }
 
-    // MARK: - Codable helpers
+    private static func severityRank(_ severity: DeviceAlertSeverity) -> Int {
+        switch severity {
+        case .critical: return 0
+        case .timeSensitive: return 1
+        case .normal: return 2
+        }
+    }
+
+    private static func activeRank(_ option: ActiveOption) -> Int {
+        switch option {
+        case .always: return 0
+        case .day: return 1
+        case .night: return 2
+        }
+    }
 
     private static func decode<T: Decodable>(
         _: T.Type,

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

@@ -0,0 +1,62 @@
+import Combine
+import Foundation
+import LoopKit
+import Swinject
+
+/// Issues a `.notLooping` alarm if no successful loop completes within the
+/// configured grace period (default 20 minutes). On every successful loop
+/// the pending alarm is retracted and a fresh delayed alarm is rescheduled
+/// — using `Alert.Trigger.delayed(interval:)` so it fires via UN even if
+/// the app is suspended.
+///
+/// Replaces the legacy `scheduleMissingLoopNotifiactions` direct-UN path
+/// in `BaseUserNotificationsManager`. The alert now flows through
+/// `TrioAlertManager` and inherits tier config from Device Alarms
+/// (Critical tier by default).
+final class NotLoopingMonitor: Injectable {
+    @Injected() private var apsManager: APSManager!
+    @Injected() private var trioAlertManager: TrioAlertManager!
+
+    /// Minutes of staleness before the alarm fires. Mirrors the legacy
+    /// `firstInterval` (20 min) — the second 40-min reminder is dropped;
+    /// retract-on-loop semantics make it redundant.
+    private static let gracePeriodMinutes: Int = 20
+
+    private static let alertID = Alert.Identifier(
+        managerIdentifier: "trio.aps",
+        alertIdentifier: "loop.notActive"
+    )
+
+    private var subscriptions = Set<AnyCancellable>()
+
+    init(resolver: Resolver) {
+        injectServices(resolver)
+        apsManager.lastLoopDateSubject
+            .sink { [weak self] _ in self?.rescheduleAlarm() }
+            .store(in: &subscriptions)
+    }
+
+    private func rescheduleAlarm() {
+        // Retract first — clears pending UN, modal timer, and throttler so the
+        // next issueAlert isn't blocked by 5-min duplicate suppression.
+        trioAlertManager.retractAlert(identifier: Self.alertID)
+
+        let content = Alert.Content(
+            title: String(localized: "Trio Not Active"),
+            body: String(
+                format: String(localized: "Last loop was more than %d min ago"),
+                Self.gracePeriodMinutes
+            ),
+            acknowledgeActionButtonLabel: String(localized: "OK")
+        )
+        let alert = Alert(
+            identifier: Self.alertID,
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .delayed(interval: TimeInterval(Self.gracePeriodMinutes * 60)),
+            interruptionLevel: .critical,
+            sound: .sound(name: "honk.caf")
+        )
+        trioAlertManager.issueAlert(alert)
+    }
+}

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

@@ -36,6 +36,7 @@ enum TrioAlertCategory: Equatable {
     case suspendTimeExpired
     case bolusFailed
     case manualTempBasalActive
+    case notLooping
     case sensorFailure
     case glucoseUrgentLow
     case glucoseLow
@@ -61,6 +62,7 @@ enum TrioAlertCategory: Equatable {
              .glucoseUrgentLow,
              .hardwareFault,
              .manualTempBasalActive,
+             .notLooping,
              .occlusion,
              .other,
              .podShutdownImminent,
@@ -81,6 +83,7 @@ enum TrioAlertCategory: Equatable {
              .deliveryUncertain,
              .glucoseUrgentLow,
              .hardwareFault,
+             .notLooping,
              .occlusion,
              .reservoirEmpty:
             return .critical
@@ -200,6 +203,10 @@ enum TrioAlertClassifier {
         if id.contains("suspendtimeexpired") || id.contains("suspendended") { return .suspendTimeExpired }
         if id.contains("manualtempbasal") { return .manualTempBasalActive }
 
+        // Loop has not run for the expected interval — emitted internally
+        // by the not-looping monitor, not by any pump manager.
+        if id.contains("notlooping") || id.contains("loop.notactive") { return .notLooping }
+
         return .other(alertIdentifier)
     }
 

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

@@ -104,9 +104,17 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         // maps to one of three tiers (Critical / Time-Sensitive / Normal) and
         // the tier config overrides sound + interruption level. Glucose
         // alarms bypass this — they're owned by `GlucoseAlertCoordinator`.
+        // If every variant in the matched tier is disabled, drop the alert.
         let effective: Alert
         if let pumpCategory = PumpAlertCategory(trioCategory: category) {
-            effective = applyDeviceSeverityConfig(to: alert, category: pumpCategory)
+            guard let configured = applyDeviceSeverityConfig(to: alert, category: pumpCategory) else {
+                debug(
+                    .service,
+                    "TrioAlertManager dropped \(alert.identifier.value): all variants in tier \(pumpCategory.defaultSeverity) disabled"
+                )
+                return
+            }
+            effective = configured
         } else {
             effective = alert
         }
@@ -137,9 +145,13 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         playCriticalAudioFallbackIfNeeded(effective, muted: muted)
     }
 
-    private func applyDeviceSeverityConfig(to alert: Alert, category: PumpAlertCategory) -> Alert {
+    private func applyDeviceSeverityConfig(to alert: Alert, category: PumpAlertCategory) -> Alert? {
         let severity = category.defaultSeverity
-        guard let config = DeviceAlertsStore.shared.config(for: severity) else { return alert }
+        let now = Date()
+        let isNight = GlucoseAlertsStore.shared.configuration.isNight(at: now)
+        guard let config = DeviceAlertsStore.shared.config(for: severity, at: now, isNight: isNight) else {
+            return nil
+        }
         let sound: Alert.Sound? = config.playsSound ? .sound(name: config.soundFilename) : nil
         let level: Alert.InterruptionLevel = config.overridesSilenceAndDND ? .critical : .timeSensitive
         return Alert(
@@ -159,6 +171,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         }
         modalScheduler.unschedule(identifier: identifier)
         userNotificationScheduler.unschedule(identifier: identifier)
+        throttler.reset(identifier: identifier)
         Task { @MainActor in criticalAudioPlayer?.stop() }
         alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
     }

+ 22 - 102
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -1,11 +1,8 @@
-import AudioToolbox
 import Combine
 import CoreData
 import Foundation
-import LoopKit
 import SwiftUI
 import Swinject
-import UIKit
 import UserNotifications
 
 protocol UserNotificationsManager {
@@ -14,12 +11,6 @@ protocol UserNotificationsManager {
     @MainActor func applySnooze(for duration: TimeInterval) async
 }
 
-enum GlucoseSourceKey: String {
-    case transmitterBattery
-    case nightscoutPing
-    case description
-}
-
 enum NotificationAction: String {
     static let key = "action"
 
@@ -28,10 +19,6 @@ enum NotificationAction: String {
     case none
 }
 
-protocol BolusFailureObserver {
-    func bolusDidFail()
-}
-
 protocol alertMessageNotificationObserver {
     func alertMessageNotification(_ message: MessageContent)
 }
@@ -46,8 +33,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     enum Identifier: String {
         case glucoseNotification = "Trio.glucoseNotification"
         case carbsRequiredNotification = "Trio.carbsRequiredNotification"
-        case noLoopFirstNotification = "Trio.noLoopFirstNotification"
-        case noLoopSecondNotification = "Trio.noLoopSecondNotification"
         case alertMessageNotification = "Trio.alertMessageNotification"
     }
 
@@ -62,7 +47,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
 
     private let notificationCenter = UNUserNotificationCenter.current()
-    private var lifetime = Lifetime()
 
     private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
     private let backgroundContext = CoreDataStack.shared.newTaskContext()
@@ -72,9 +56,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
     private var subscriptions = Set<AnyCancellable>()
 
-    let firstInterval = 20 // min
-    let secondInterval = 40 // min
-
     init(resolver: Resolver) {
         super.init()
         notificationCenter.delegate = self
@@ -88,14 +69,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
 
         broadcaster.register(DeterminationObserver.self, observer: self)
         broadcaster.register(alertMessageNotificationObserver.self, observer: self)
-//        requestNotificationPermissionsIfNeeded()
-        Task {
-            await sendGlucoseNotification()
-        }
+        Task { await updateGlucoseBadge() }
         configureNotificationCategories()
-        registerHandlers()
-        registerSubscribers()
-        subscribeOnLoop()
+        subscribeGlucoseUpdates()
     }
 
     private func configureNotificationCategories() {
@@ -114,33 +90,19 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
-    private func subscribeOnLoop() {
-        apsManager.lastLoopDateSubject
-            .sink { [weak self] date in
-                self?.scheduleMissingLoopNotifiactions(date: date)
-            }
-            .store(in: &lifetime)
-    }
-
-    private func registerHandlers() {
-        // Due to the Batch insert this only is used for observing Deletion of Glucose entries
-        coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
-            guard let self = self else { return }
-            Task {
-                await self.sendGlucoseNotification()
-            }
-        }.store(in: &subscriptions)
-    }
-
-    private func registerSubscribers() {
+    /// Subscribes to the two sources that signal a glucose change so the app
+    /// icon badge stays current:
+    /// - `coreDataPublisher` filtered to `GlucoseStored` — catches deletions
+    ///   (batch inserts don't fire normal Core Data save notifications, so
+    ///   inserts come through `updatePublisher` below).
+    /// - `glucoseStorage.updatePublisher` — fires on every new reading.
+    private func subscribeGlucoseUpdates() {
+        coreDataPublisher?.filteredByEntityName("GlucoseStored")
+            .sink { [weak self] _ in Task { await self?.updateGlucoseBadge() } }
+            .store(in: &subscriptions)
         glucoseStorage.updatePublisher
             .receive(on: DispatchQueue.global(qos: .background))
-            .sink { [weak self] _ in
-                guard let self = self else { return }
-                Task {
-                    await self.sendGlucoseNotification()
-                }
-            }
+            .sink { [weak self] _ in Task { await self?.updateGlucoseBadge() } }
             .store(in: &subscriptions)
     }
 
@@ -201,41 +163,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         addRequest(identifier: .carbsRequiredNotification, content: content, deleteOld: true, messageSubtype: .carb)
     }
 
-    private func scheduleMissingLoopNotifiactions(date _: Date) {
-        let title = String(localized: "Trio Not Active", comment: "Trio Not Active")
-        let body = String(localized: "Last loop was more than %d min ago", comment: "Last loop was more than %d min ago")
-
-        let firstContent = UNMutableNotificationContent()
-        firstContent.title = title
-        firstContent.body = String(format: body, firstInterval)
-        firstContent.sound = .default
-
-        let secondContent = UNMutableNotificationContent()
-        secondContent.title = title
-        secondContent.body = String(format: body, secondInterval)
-        secondContent.sound = .default
-
-        let firstTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(firstInterval), repeats: false)
-        let secondTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(secondInterval), repeats: false)
-
-        addRequest(
-            identifier: .noLoopFirstNotification,
-            content: firstContent,
-            deleteOld: true,
-            trigger: firstTrigger,
-            messageType: .error,
-            messageSubtype: .algorithm
-        )
-        addRequest(
-            identifier: .noLoopSecondNotification,
-            content: secondContent,
-            deleteOld: true,
-            trigger: secondTrigger,
-            messageType: .error,
-            messageSubtype: .algorithm
-        )
-    }
-
     private func fetchGlucoseIDs() async throws -> [NSManagedObjectID] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
@@ -255,22 +182,20 @@ 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 {
+    /// Refreshes the Trio app icon badge from the latest stored glucose
+    /// reading. Glucose alarm emission has moved to `GlucoseAlertCoordinator`
+    /// (urgent-low / low / forecasted-low / high are issued via
+    /// `TrioAlertManager` based on the user-configured `[GlucoseAlert]` list).
+    @MainActor private func updateGlucoseBadge() async {
         do {
             addAppBadge(glucose: nil)
             let glucoseIDs = try await fetchGlucoseIDs()
-            let glucoseObjects = try glucoseIDs.compactMap { id in
+            let latest = try glucoseIDs.compactMap { id in
                 try viewContext.existingObject(with: id) as? GlucoseStored
-            }
-            addAppBadge(glucose: (glucoseObjects.first?.glucose).map { Int($0) })
+            }.first?.glucose
+            addAppBadge(glucose: latest.map { Int($0) })
         } catch {
-            debugPrint(
-                "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to update glucose badge with error: \(error)"
-            )
+            debug(.service, "Failed to update glucose badge: \(error)")
         }
     }
 
@@ -390,11 +315,6 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
             identifier = .carbsRequiredNotification
         case .glucose:
             identifier = .glucoseNotification
-        case .algorithm:
-            if message.trigger != nil {
-                identifier = message.content.contains(String(firstInterval)) ? Identifier.noLoopFirstNotification : Identifier
-                    .noLoopSecondNotification
-            }
         default:
             identifier = .alertMessageNotification
         }