Explorar el Código

Introduce new alert system to Trio
* Route LoopKit alerts through TrioAlertManager
* BaseDeviceDataManager.issueAlert/retractAlert forward to new TrioAlertManager
* Drop the AlertObserver auto-ack and pumpNotification broadcast
* TrioAlertCategory enum + classifier covers Omni, Dana, Minimed
alert identifiers; .commsTransient and .algorithmError dwell-
suppressed (count=2 or 60s), everything else surfaces
* APSManager.processError synthesises a LoopKit alert via the
classifier; manual bolus + cancelBolus catches emit explicit
.bolusFailed alerts so they always fire
* SwiftUI in-app banner (iOS-push styling, SF Symbol on tinted
square, live timestamp, tap/swipe-to-ack, stacked deck with
critical-on-top, tap top to expand)
* Drop notificationsPump/Cgm/Carb/Algorithm settings
* Test alert buttons in PumpConfig (need to be removed later)

Deniz Cengiz hace 4 semanas
padre
commit
ec30ee2dae

+ 24 - 0
Trio.xcodeproj/project.pbxproj

@@ -179,6 +179,10 @@
 		38E87401274F77E400975559 /* CoreNFC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38E873FD274F761800975559 /* CoreNFC.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
 		38E87403274F78C000975559 /* libswiftCoreNFC.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 38E87402274F78C000975559 /* libswiftCoreNFC.tbd */; settings = {ATTRIBUTES = (Weak, ); }; };
 		38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E87407274F9AD000975559 /* UserNotificationsManager.swift */; };
+		BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179212F4E22C100F90001 /* TrioAlertManager.swift */; };
+		BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */; };
+		BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */; };
+		BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1179292F4E22C100F90001 /* TrioAlertCategory.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 */; };
@@ -1070,6 +1074,10 @@
 		38E873FD274F761800975559 /* CoreNFC.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreNFC.framework; path = System/Library/Frameworks/CoreNFC.framework; sourceTree = SDKROOT; };
 		38E87402274F78C000975559 /* libswiftCoreNFC.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libswiftCoreNFC.tbd; path = usr/lib/swift/libswiftCoreNFC.tbd; sourceTree = SDKROOT; };
 		38E87407274F9AD000975559 /* UserNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationsManager.swift; sourceTree = "<group>"; };
+		BD1179212F4E22C100F90001 /* TrioAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertManager.swift; sourceTree = "<group>"; };
+		BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertScheduler.swift; sourceTree = "<group>"; };
+		BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioUserNotificationAlertScheduler.swift; sourceTree = "<group>"; };
+		BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertCategory.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>"; };
@@ -2134,6 +2142,7 @@
 		3811DE9125C9D88200A708ED /* Services */ = {
 			isa = PBXGroup;
 			children = (
+				BD1179262F4E22C100F90001 /* Alerts */,
 				3811DE9225C9D88200A708ED /* Appearance */,
 				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
@@ -2158,6 +2167,17 @@
 			path = Services;
 			sourceTree = "<group>";
 		};
+		BD1179262F4E22C100F90001 /* Alerts */ = {
+			isa = PBXGroup;
+			children = (
+				BD1179292F4E22C100F90001 /* TrioAlertCategory.swift */,
+				BD1179212F4E22C100F90001 /* TrioAlertManager.swift */,
+				BD1179232F4E22C100F90001 /* TrioModalAlertScheduler.swift */,
+				BD1179252F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift */,
+			);
+			path = Alerts;
+			sourceTree = "<group>";
+		};
 		DD7E1E30000000000000000A /* Telemetry */ = {
 			isa = PBXGroup;
 			children = (
@@ -4588,6 +4608,10 @@
 				BD249D8C2D42FC2C00412DEB /* GlucoseDistributionChart.swift in Sources */,
 				DDFF20312DB1D15500AB8A96 /* BluetoothPermissionStepView.swift in Sources */,
 				38E87408274F9AD000975559 /* UserNotificationsManager.swift in Sources */,
+				BD1179202F4E22C100F90001 /* TrioAlertManager.swift in Sources */,
+				BD1179222F4E22C100F90001 /* TrioModalAlertScheduler.swift in Sources */,
+				BD1179242F4E22C100F90001 /* TrioUserNotificationAlertScheduler.swift in Sources */,
+				BD1179282F4E22C100F90001 /* TrioAlertCategory.swift in Sources */,
 				DD3F1F902D9E153F00DCE7B3 /* NightscoutImportStepView.swift in Sources */,
 				CE82E02528E867BA00473A9C /* AlertStorage.swift in Sources */,
 				DD1745372C55B74200211FAC /* AlgorithmSettings.swift in Sources */,

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

@@ -57,17 +57,6 @@ enum APSError: LocalizedError {
             return String(localized: "Manual Temporary Basal Rate (\(message)). Looping suspended.")
         }
     }
-
-    static func pumpErrorMatches(message: String) -> Bool {
-        message.contains(String(localized: "Pump Error"))
-    }
-
-    static func pumpWarningMatches(message: String) -> Bool {
-        message.contains(String(localized: "Invalid Pump State")) || message
-            .contains("PumpMessage") || message
-            .contains("PumpOpsError") || message.contains("RileyLink") || message
-            .contains(String(localized: "Pump did not respond in time"))
-    }
 }
 
 final class BaseAPSManager: APSManager, Injectable {
@@ -82,6 +71,7 @@ final class BaseAPSManager: APSManager, Injectable {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var tddStorage: TDDStorage!
     @Injected() private var broadcaster: Broadcaster!
+    @Injected() private var trioAlertManager: TrioAlertManager!
     @Persisted(key: "lastLoopStartDate") private var lastLoopStartDate: Date = .distantPast
     @Persisted(key: "lastLoopDate") var lastLoopDate: Date = .distantPast {
         didSet {
@@ -369,6 +359,8 @@ final class BaseAPSManager: APSManager, Injectable {
             debug(.apsManager, "Loop succeeded")
             lastLoopDate = Date()
             lastError.send(nil)
+            transientCategoryFirstSeen.removeAll()
+            transientCategoryCount.removeAll()
         }
 
         loopStats(loopStatRecord: loopStatRecord)
@@ -597,9 +589,14 @@ final class BaseAPSManager: APSManager, Injectable {
             }
         } catch {
             warning(.apsManager, "Bolus failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus failed"),
+                body: String(localized: "Check pump history before repeating.")
+                    + "\n\n\(error.localizedDescription)"
+            )
             if !isSMB {
-                // Use MainActor to handle broadcaster notification
                 let broadcaster = self.broadcaster
                 Task { @MainActor in
                     broadcaster?.notify(BolusFailureObserver.self, on: .main) {
@@ -623,7 +620,12 @@ final class BaseAPSManager: APSManager, Injectable {
             callback?(true, String(localized: "Bolus cancelled successfully.", comment: "Success message for canceling a bolus"))
         } catch {
             debug(.apsManager, "Bolus cancellation failed with error: \(error)")
-            processError(APSError.pumpError(error))
+            lastError.send(APSError.pumpError(error))
+            issueAlertForCategory(
+                .bolusFailed,
+                title: String(localized: "Bolus cancellation failed"),
+                body: String(localized: "Try again.") + "\n\n\(error.localizedDescription)"
+            )
             callback?(
                 false,
                 String(
@@ -714,10 +716,9 @@ final class BaseAPSManager: APSManager, Injectable {
             throw APSError.apsError(message: "Pump not set")
         }
 
-        // Check if pump is suspended and abort if it is
         if pump.status.pumpStatus.suspended {
-            info(.apsManager, "Skipping enactDetermination because pump is suspended")
-            return // return without throwing an error
+            debug(.apsManager, "Skipping enactDetermination because pump is suspended")
+            return
         }
 
         // Unable to do temp basal during manual temp basal 😁
@@ -1208,9 +1209,119 @@ final class BaseAPSManager: APSManager, Injectable {
         }
     }
 
+    private var transientCategoryFirstSeen: [String: Date] = [:]
+    private var transientCategoryCount: [String: Int] = [:]
+    private static let transientDwellThreshold: TimeInterval = 60
+    private static let transientCountThreshold = 2
+
     private func processError(_ error: Error) {
         warning(.apsManager, "\(error)")
         lastError.send(error)
+        surfaceErrorIfNeeded(error)
+    }
+
+    private func surfaceErrorIfNeeded(_ error: Error) {
+        let category = TrioAlertClassifier.categorize(error: error)
+        let key = String(describing: category)
+
+        if category.isAlertWorthy {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+            return
+        }
+
+        let now = Date()
+        let firstSeen = transientCategoryFirstSeen[key] ?? now
+        let count = (transientCategoryCount[key] ?? 0) + 1
+        let dwellElapsed = now.timeIntervalSince(firstSeen)
+        let dwellMet = dwellElapsed >= Self.transientDwellThreshold
+        let countMet = count >= Self.transientCountThreshold
+
+        if dwellMet || countMet {
+            transientCategoryFirstSeen.removeValue(forKey: key)
+            transientCategoryCount.removeValue(forKey: key)
+            issueAlertForError(error, category: category)
+        } else {
+            transientCategoryFirstSeen[key] = firstSeen
+            transientCategoryCount[key] = count
+            debug(
+                .apsManager,
+                "APSManager suppressed transient \(category) (count=\(count)/\(Self.transientCountThreshold), dwell=\(Int(dwellElapsed))s/\(Int(Self.transientDwellThreshold))s)"
+            )
+        }
+    }
+
+    private func issueAlertForCategory(_ category: TrioAlertCategory, title: String, body: String) {
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: String(localized: "OK")
+        )
+        let alertId: String
+        switch category {
+        case .bolusFailed: alertId = "bolusFailed"
+        case .pumpFault: alertId = "pumpFault"
+        case .occlusion: alertId = "occlusion"
+        case .reservoirEmpty: alertId = "reservoirEmpty"
+        case .reservoirLow: alertId = "reservoirLow"
+        case .batteryEmpty: alertId = "batteryEmpty"
+        case .batteryLow: alertId = "batteryLow"
+        case .manualTempBasalActive: alertId = "manualTempBasalActive"
+        case .glucoseDataStale: alertId = "glucoseDataStale"
+        default: alertId = "general"
+        }
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: alertId),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
+    }
+
+    private func issueAlertForError(_ error: Error, category: TrioAlertCategory) {
+        let (alertId, title, body) = describeForAlert(error, category: category)
+        let content = Alert.Content(
+            title: title,
+            body: body,
+            acknowledgeActionButtonLabel: "OK"
+        )
+        let alert = Alert(
+            identifier: Alert.Identifier(managerIdentifier: "trio.aps", alertIdentifier: alertId),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: category.interruptionLevel
+        )
+        trioAlertManager?.issueAlert(alert)
+    }
+
+    private func describeForAlert(
+        _ error: Error,
+        category _: TrioAlertCategory
+    ) -> (alertId: String, title: String, body: String) {
+        if let apsError = error as? APSError {
+            switch apsError {
+            case let .pumpError(inner):
+                return (
+                    "pumpError",
+                    String(localized: "Pump Error"),
+                    String(localized: "Trio could not communicate with the pump. Check the pump and try again.")
+                        + "\n\n\(inner.localizedDescription)"
+                )
+            case let .invalidPumpState(message):
+                return ("invalidPumpState", String(localized: "Pump State"), message)
+            case let .glucoseError(message):
+                return ("glucoseError", String(localized: "Glucose"), message)
+            case let .apsError(message):
+                return ("algorithmError", String(localized: "Algorithm"), message)
+            case let .manualBasalTemp(message):
+                return ("manualBasalTemp", String(localized: "Manual Temp Basal"), message)
+            }
+        }
+        return ("general", "Trio", error.localizedDescription)
     }
 
     private func createBolusReporter() {

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

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

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

@@ -17,6 +17,8 @@ final class ServiceAssembly: Assembly {
         container.register(CalendarManager.self) { r in BaseCalendarManager(resolver: r) }
         container.register(HKHealthStore.self) { _ in HKHealthStore() }
         container.register(HealthKitManager.self) { r in BaseHealthKitManager(resolver: r) }
+        container.register(TrioAlertManager.self) { r in BaseTrioAlertManager(resolver: r) }
+            .inObjectScope(.container)
         container.register(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) }

+ 69 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -10035,6 +10035,18 @@
         }
       }
     },
+    "%dh ago" : {
+      "comment" : "A time ago string, in the format \"X hours ago\".",
+      "isCommentAutoGenerated" : true
+    },
+    "%dm ago" : {
+      "comment" : "A relative timestamp, in minutes, for an alert.",
+      "isCommentAutoGenerated" : true
+    },
+    "%ds ago" : {
+      "comment" : "A relative timestamp, in seconds, for a past event.",
+      "isCommentAutoGenerated" : true
+    },
     "%lld" : {
       "localizations" : {
         "bg" : {
@@ -42525,6 +42537,7 @@
       }
     },
     "Always Notify Algorithm" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -42649,6 +42662,7 @@
       }
     },
     "Always Notify Algorithm Warnings." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -42773,6 +42787,7 @@
       }
     },
     "Always Notify Carb" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -42897,6 +42912,7 @@
       }
     },
     "Always Notify Carb Warnings." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -43021,6 +43037,7 @@
       }
     },
     "Always Notify CGM" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -43145,6 +43162,7 @@
       }
     },
     "Always Notify CGM Warnings." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -43400,6 +43418,7 @@
       }
     },
     "Always Notify Pump" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -43524,6 +43543,7 @@
       }
     },
     "Always Notify Pump Warnings." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -44399,6 +44419,7 @@
       }
     },
     "An example of a Carb Warning is 'Carbs required: 30 g'" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -44523,6 +44544,7 @@
       }
     },
     "An example of a CGM Warning is 'Unable to open the app'" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -44647,6 +44669,7 @@
       }
     },
     "An example of a Pump Warning is 'Pod Expiration Reminder'" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -44771,6 +44794,7 @@
       }
     },
     "An example of an Algorithm Warning is 'Error: Invalid glucose: Not enough glucose data'" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -58617,6 +58641,10 @@
         }
       }
     },
+    "Bolus cancellation failed" : {
+      "comment" : "Title of an alert when a bolus is not cancelled.",
+      "isCommentAutoGenerated" : true
+    },
     "Bolus cancelled successfully." : {
       "comment" : "Success message for canceling a bolus",
       "localizations" : {
@@ -59242,6 +59270,7 @@
     },
     "Bolus failed or inaccurate. Check pump history before repeating." : {
       "comment" : "Bolus failed or inaccurate. Check pump history before repeating.",
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -69979,6 +70008,10 @@
         }
       }
     },
+    "Check pump history before repeating." : {
+      "comment" : "Error message displayed when a bolus fails.",
+      "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" : {
@@ -122179,6 +122212,14 @@
         }
       }
     },
+    "Fire test alert (critical)" : {
+      "comment" : "A button that fires a critical test alert.",
+      "isCommentAutoGenerated" : true
+    },
+    "Fire test alert (immediate)" : {
+      "comment" : "A button that fires a test alert.",
+      "isCommentAutoGenerated" : true
+    },
     "Firmware" : {
       "extractionState" : "manual",
       "localizations" : {
@@ -139701,6 +139742,7 @@
       }
     },
     "If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -148159,6 +148201,7 @@
       }
     },
     "Invalid Pump State" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -187050,6 +187093,10 @@
         }
       }
     },
+    "now" : {
+      "comment" : "A suffix for \"Trio now\".",
+      "isCommentAutoGenerated" : true
+    },
     "Now" : {
       "localizations" : {
         "bg" : {
@@ -201987,6 +202034,7 @@
       }
     },
     "Pump did not respond in time" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -203621,6 +203669,10 @@
         }
       }
     },
+    "Pump State" : {
+      "comment" : "Title of an alert when an invalid pump state is detected.",
+      "isCommentAutoGenerated" : true
+    },
     "Pump suspended" : {
       "localizations" : {
         "bg" : {
@@ -211133,6 +211185,10 @@
         }
       }
     },
+    "Retract test alerts" : {
+      "comment" : "A button that retracts test alerts.",
+      "isCommentAutoGenerated" : true
+    },
     "Retry" : {
       "localizations" : {
         "bg" : {
@@ -265955,6 +266011,10 @@
         }
       }
     },
+    "Trio could not communicate with the pump. Check the pump and try again." : {
+      "comment" : "Error message displayed when the pump is not responding.",
+      "isCommentAutoGenerated" : true
+    },
     "Trio could not communicate with your pump. Changes to your basal profile were not saved." : {
       "localizations" : {
         "bg" : {
@@ -267449,6 +267509,7 @@
       }
     },
     "Trio Information Notifications" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -270682,6 +270743,10 @@
         }
       }
     },
+    "Try again." : {
+      "comment" : "Error message displayed when a bolus cancellation fails.",
+      "isCommentAutoGenerated" : true
+    },
     "Turn this setting on to give basal adjustments more agility. Keep this setting off if your basal needs are not highly variable." : {
       "localizations" : {
         "bg" : {
@@ -287177,6 +287242,7 @@
       }
     },
     "With iOS Trio Notifications enabled, you can let Trio display most Algorithm Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -287301,6 +287367,7 @@
       }
     },
     "With iOS Trio Notifications enabled, you can let Trio display most Carb Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -287425,6 +287492,7 @@
       }
     },
     "With iOS Trio Notifications enabled, you can let Trio display most CGM Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -287549,6 +287617,7 @@
       }
     },
     "With iOS Trio Notifications enabled, you can let Trio display most Pump Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed." : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {

+ 0 - 20
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 notificationsPump: Bool = true
-    var notificationsCgm: Bool = true
-    var notificationsCarb: Bool = true
-    var notificationsAlgorithm: Bool = true
     var glucoseNotificationsOption: GlucoseNotificationsOption = .onlyAlarmLimits
     var addSourceInfoToGlucoseNotifications: Bool = false
     var lowGlucose: Decimal = 72
@@ -213,22 +209,6 @@ extension TrioSettings: Decodable {
             settings.delay = delay
         }
 
-        if let notificationsPump = try? container.decode(Bool.self, forKey: .notificationsPump) {
-            settings.notificationsPump = notificationsPump
-        }
-
-        if let notificationsCgm = try? container.decode(Bool.self, forKey: .notificationsCgm) {
-            settings.notificationsCgm = notificationsCgm
-        }
-
-        if let notificationsCarb = try? container.decode(Bool.self, forKey: .notificationsCarb) {
-            settings.notificationsCarb = notificationsCarb
-        }
-
-        if let notificationsAlgorithm = try? container.decode(Bool.self, forKey: .notificationsAlgorithm) {
-            settings.notificationsAlgorithm = notificationsAlgorithm
-        }
-
         if let glucoseNotificationsOption = try? container.decode(
             GlucoseNotificationsOption.self,
             forKey: .glucoseNotificationsOption

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

@@ -8,22 +8,12 @@ extension GlucoseNotificationSettings {
         @Published var lowGlucose: Decimal = 0
         @Published var highGlucose: Decimal = 0
 
-        @Published var notificationsPump = true
-        @Published var notificationsCgm = true
-        @Published var notificationsCarb = true
-        @Published var notificationsAlgorithm = true
-
         var units: GlucoseUnits = .mgdL
 
         override func subscribe() {
             let units = settingsManager.settings.units
             self.units = units
 
-            subscribeSetting(\.notificationsPump, on: $notificationsPump) { notificationsPump = $0 }
-            subscribeSetting(\.notificationsCgm, on: $notificationsCgm) { notificationsCgm = $0 }
-            subscribeSetting(\.notificationsCarb, on: $notificationsCarb) { notificationsCarb = $0 }
-            subscribeSetting(\.notificationsAlgorithm, on: $notificationsAlgorithm) { notificationsAlgorithm = $0 }
-
             subscribeSetting(\.glucoseBadge, on: $glucoseBadge) { glucoseBadge = $0 }
             subscribeSetting(\.glucoseNotificationsOption, on: $glucoseNotificationsOption) { glucoseNotificationsOption = $0 }
             subscribeSetting(\.addSourceInfoToGlucoseNotifications, on: $addSourceInfoToGlucoseNotifications) {

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

@@ -42,110 +42,6 @@ extension GlucoseNotificationSettings {
             List {
                 SettingInputSection(
                     decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsPump,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Pump")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Pump"),
-                    miniHint: String(localized: "Always Notify Pump Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Pump Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a Pump Warning is 'Pod Expiration Reminder'")
-                    },
-                    headerText: String(localized: "Trio Information Notifications")
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsCgm,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify CGM")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify CGM"),
-                    miniHint: String(localized: "Always Notify CGM Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most CGM Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a CGM Warning is 'Unable to open the app'")
-                    }
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsCarb,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Carb")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Carb"),
-                    miniHint: String(localized: "Always Notify Carb Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Carb Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text("An example of a Carb Warning is 'Carbs required: 30 g'")
-                    }
-                )
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
-                    booleanValue: $state.notificationsAlgorithm,
-                    shouldDisplayHint: $shouldDisplayHint,
-                    selectedVerboseHint: Binding(
-                        get: { selectedVerboseHint },
-                        set: {
-                            selectedVerboseHint = $0.map { AnyView($0) }
-                            hintLabel = String(localized: "Always Notify Algorithm")
-                        }
-                    ),
-                    units: state.units,
-                    type: .boolean,
-                    label: String(localized: "Always Notify Algorithm"),
-                    miniHint: String(localized: "Always Notify Algorithm Warnings."),
-                    verboseHint:
-                    VStack(alignment: .leading, spacing: 10) {
-                        Text("Default: ON").bold()
-                        Text(
-                            "With iOS Trio Notifications enabled, you can let Trio display most Algorithm Notifications in iOS Notification Center as a Banner, List and on the Lock Screen. It allows you to refer to Trio Information at a glance and troubleshoot any informational issue. Set iOS Notifications Banner Style to Persistent to display banners in the app until dismissed."
-                        )
-                        Text("If iOS Trio Notifications is disabled, Trio will display these messages in-app as a banner only.")
-                        Text(
-                            "An example of an Algorithm Warning is 'Error: Invalid glucose: Not enough glucose data'"
-                        )
-                    }
-                )
-
-                SettingInputSection(
-                    decimalValue: $decimalPlaceholder,
                     booleanValue: $state.glucoseBadge,
                     shouldDisplayHint: $shouldDisplayHint,
                     selectedVerboseHint: Binding(

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

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

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

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

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

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

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

@@ -1,3 +1,4 @@
+import Foundation
 import LoopKit
 import LoopKitUI
 import SwiftDate
@@ -11,6 +12,7 @@ extension PumpConfig {
         private(set) var initialSettings: PumpInitialSettings = .default
         @Published var hasUnacknowledgedAlert: Bool = false
         @Injected() var bluetoothManager: BluetoothStateManager!
+        @Injected() var trioAlertManager: TrioAlertManager!
 
         override func subscribe() {
             provider.pumpDisplayState
@@ -49,7 +51,32 @@ extension PumpConfig {
         }
 
         func ack() {
-            provider.deviceManager.alertHistoryStorage.broadcastAlertUpdates()
+            trioAlertManager.acknowledgeAllOutstanding()
+        }
+
+        func fireTestAlert(critical: Bool) {
+            let identifier = Alert.Identifier(
+                managerIdentifier: "Trio.test",
+                alertIdentifier: "test-\(UUID().uuidString.prefix(8))"
+            )
+            let content = Alert.Content(
+                title: critical ? "Critical test alert" : "Test alert",
+                body: "If you see this banner in-app, the foreground modal scheduler is wired. Background it before tapping to test the UN push path.",
+                acknowledgeActionButtonLabel: "OK"
+            )
+            let alert = Alert(
+                identifier: identifier,
+                foregroundContent: content,
+                backgroundContent: content,
+                trigger: .immediate,
+                interruptionLevel: critical ? .critical : .timeSensitive
+            )
+            trioAlertManager.issueAlert(alert)
+        }
+
+        func retractTestAlerts() {
+            let identifier = Alert.Identifier(managerIdentifier: "Trio.test", alertIdentifier: "test")
+            trioAlertManager.retractAlert(identifier: identifier)
         }
     }
 }

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

@@ -48,6 +48,10 @@ extension PumpConfig {
                                     Spacer()
                                     Button("Acknowledge all alerts") { state.ack() }
                                 }
+                                Spacer()
+                                Button("Fire test alert (immediate)") { state.fireTestAlert(critical: false) }
+                                Button("Fire test alert (critical)") { state.fireTestAlert(critical: true) }
+                                Button("Retract test alerts") { state.retractTestAlerts() }
                             } else {
                                 VStack {
                                     Button {

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

@@ -850,30 +850,6 @@ extension SettingsExport {
                 addSetting(
                     category: notificationsCategory,
                     subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Always Notify Pump"),
-                    value: trioSettings.notificationsPump ? String(localized: "Enabled") : String(localized: "Disabled")
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Always Notify CGM"),
-                    value: trioSettings.notificationsCgm ? String(localized: "Enabled") : String(localized: "Disabled")
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Always Notify Carb"),
-                    value: trioSettings.notificationsCarb ? String(localized: "Enabled") : String(localized: "Disabled")
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
-                    name: String(localized: "Always Notify Algorithm"),
-                    value: trioSettings.notificationsAlgorithm ? String(localized: "Enabled") : String(localized: "Disabled")
-                )
-                addSetting(
-                    category: notificationsCategory,
-                    subcategory: trioNotificationsSubcategory,
                     name: String(localized: "Show Glucose App Badge"),
                     value: trioSettings.glucoseBadge ? String(localized: "Enabled") : String(localized: "Disabled")
                 )

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

@@ -52,23 +52,12 @@ final class BaseRouter: Router {
 
     func allowNotify(_ message: MessageContent, _ settings: TrioSettings) -> Bool {
         if message.type == .error { return true }
-        switch message.subtype {
-        case .pump:
-            guard settings.notificationsPump else { return false }
-        case .cgm:
-            guard settings.notificationsCgm else { return false }
-        case .carb:
-            guard settings.notificationsCarb else { return false }
-        case .glucose:
-            guard (
+        if message.subtype == .glucose {
+            return (
                 message.type == .warning &&
                     settings.glucoseNotificationsOption == GlucoseNotificationsOption.onlyAlarmLimits
             ) ||
-                settings.glucoseNotificationsOption == GlucoseNotificationsOption.alwaysEveryCGM else { return false }
-        case .algorithm:
-            guard settings.notificationsAlgorithm else { return false }
-        case .misc:
-            return true
+                settings.glucoseNotificationsOption == GlucoseNotificationsOption.alwaysEveryCGM
         }
         return true
     }

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

@@ -0,0 +1,147 @@
+import Foundation
+import LoopKit
+
+enum TrioAlertCategory: Equatable {
+    case occlusion
+    case reservoirLow
+    case reservoirEmpty
+    case batteryLow
+    case batteryEmpty
+    case pumpFault
+    case podExpirationReminder
+    case podExpired
+    case podShutdownImminent
+    case suspendTimeExpired
+    case bolusFailed
+    case manualTempBasalActive
+    case glucoseDataStale
+    case algorithmError
+    case commsTransient
+    case other(String)
+
+    var isAlertWorthy: Bool {
+        switch self {
+        case .batteryEmpty,
+             .batteryLow,
+             .bolusFailed,
+             .glucoseDataStale,
+             .manualTempBasalActive,
+             .occlusion,
+             .other,
+             .podExpirationReminder,
+             .podExpired,
+             .podShutdownImminent,
+             .pumpFault,
+             .reservoirEmpty,
+             .reservoirLow,
+             .suspendTimeExpired:
+            return true
+        case .algorithmError,
+             .commsTransient:
+            return false
+        }
+    }
+
+    var interruptionLevel: Alert.InterruptionLevel {
+        switch self {
+        case .batteryEmpty,
+             .occlusion,
+             .pumpFault,
+             .reservoirEmpty:
+            return .critical
+        case .batteryLow,
+             .bolusFailed,
+             .glucoseDataStale,
+             .manualTempBasalActive,
+             .podExpired,
+             .podShutdownImminent,
+             .reservoirLow,
+             .suspendTimeExpired:
+            return .timeSensitive
+        case .algorithmError,
+             .commsTransient,
+             .other,
+             .podExpirationReminder:
+            return .active
+        }
+    }
+}
+
+enum TrioAlertClassifier {
+    /// Classify a LoopKit alert identifier coming from a pump or CGM manager.
+    /// Returns `.other(identifier)` for anything not recognized — keeps untyped
+    /// alerts flowing through but marks them as not-alert-worthy by default.
+    ///
+    /// Coverage mapping by pump manager:
+    ///   - Omni family (OmniBLE / OmniKit / OmnipodKit): `userPodExpiration`,
+    ///     `podExpiring`, `podExpireImminent`, `lowReservoir`, `suspendEnded`
+    ///     (+ untyped: `suspendInProgress`, `finishSetupReminder`,
+    ///     `unexpectedAlert`, `timeOffsetChangeDetected`).
+    ///   - DanaKit: `occlusion`, `pumpError`, `lowBattery`, `batteryZeroPercent`,
+    ///     `shutdown`, `emptyReservoir`, `remainingInsulinLevel`, `checkShaft`
+    ///     (+ untyped: `basalCompare`, `bloodSugarMeasure`, `basalMax`,
+    ///     `dailyMax`, `bloodSugarCheckMiss`, `ble5InvalidKeys`, `unknown`).
+    ///   - MinimedKit: `lowRLBattery`, `PumpBatteryLow`, `PumpReservoirEmpty`,
+    ///     `PumpReservoirLow`.
+    ///   - MedtrumKit: bypasses LoopKit `AlertIssuer` and posts to
+    ///     `UNUserNotificationCenter` directly — alerts do not reach this
+    ///     classifier today. Requires a submodule change to issue
+    ///     `delegate?.issueAlert(_:)` instead.
+    static func categorize(alertIdentifier: String) -> TrioAlertCategory {
+        let id = alertIdentifier.lowercased()
+        if id.contains("occlusion") { return .occlusion }
+        if id.contains("reservoirempty") || id.contains("emptyreservoir") { return .reservoirEmpty }
+        if id.contains("lowreservoir") || id.contains("reservoirlow") || id.contains("remaininginsulin") {
+            return .reservoirLow
+        }
+        if id.contains("batteryempty") || id.contains("batteryzero") { return .batteryEmpty }
+        if id.contains("lowbattery") || id.contains("batterylow") || id.contains("rlbattery") { return .batteryLow }
+        if id.contains("shutdownimminent") || id.contains("expireimminent") { return .podShutdownImminent }
+        if id.contains("podexpired") || id.contains("podexpiring") || id.contains("expired") { return .podExpired }
+        if id.contains("expirationreminder") || id.contains("userpodexpiration") { return .podExpirationReminder }
+        if id.contains("suspendtimeexpired") || id.contains("suspendended") { return .suspendTimeExpired }
+        if id.contains("fault") || id.contains("pumperror") || id.contains("checkshaft") || id == "shutdown" {
+            return .pumpFault
+        }
+        if id.contains("bolusfailed") { return .bolusFailed }
+        if id.contains("manualtempbasal") { return .manualTempBasalActive }
+        if id.contains("glucose"), id.contains("stale") { return .glucoseDataStale }
+        return .other(alertIdentifier)
+    }
+
+    /// Classify a Swift error caught at the `APSManager` boundary — these don't
+    /// come with a LoopKit alert identifier so we inspect the type + description.
+    static func categorize(error: Error) -> TrioAlertCategory {
+        if let apsError = error as? APSError {
+            switch apsError {
+            case let .pumpError(inner):
+                return categorize(pumpError: inner)
+            case .invalidPumpState:
+                return .pumpFault
+            case .glucoseError:
+                return .glucoseDataStale
+            case .apsError:
+                return .algorithmError
+            case .manualBasalTemp:
+                return .manualTempBasalActive
+            }
+        }
+        return categorize(pumpError: error)
+    }
+
+    private static func categorize(pumpError: Error) -> TrioAlertCategory {
+        let description = String(describing: pumpError).lowercased()
+        if description.contains("occlusion") { return .occlusion }
+        if description.contains("reservoirempty") || description.contains("emptyreservoir") { return .reservoirEmpty }
+        if description.contains("lowreservoir") { return .reservoirLow }
+        if description.contains("fault") { return .pumpFault }
+        if description.contains("podexpired") { return .podExpired }
+        if description.contains("communication") || description.contains("comms") || description.contains("notconnected")
+            || description.contains("noresponse") || description.contains("timeout") || description.contains("rssi")
+        {
+            return .commsTransient
+        }
+        if description.contains("bolusfailed") || description.contains("uncertaindelivery") { return .bolusFailed }
+        return .other(String(describing: pumpError))
+    }
+}

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

@@ -0,0 +1,289 @@
+import Combine
+import Foundation
+import LoopKit
+import Swinject
+import UserNotifications
+
+protocol TrioAlertManager: AnyObject {
+    func issueAlert(_ alert: Alert)
+    func retractAlert(identifier: Alert.Identifier)
+
+    func register(responder: AlertResponder, for managerIdentifier: String)
+    func register(soundVendor: AlertSoundVendor, for managerIdentifier: String)
+    func unregister(managerIdentifier: String)
+
+    func handleAcknowledgement(identifier: Alert.Identifier)
+    func handleNotificationResponse(_ response: UNNotificationResponse)
+    func acknowledgeAllOutstanding()
+
+    var muter: AlertMuter { get }
+    var modalScheduler: TrioModalAlertScheduler { get }
+
+    func soundURL(for alert: Alert) -> URL?
+}
+
+final class BaseTrioAlertManager: TrioAlertManager, Injectable {
+    private struct WeakRef {
+        weak var ref: AnyObject?
+    }
+
+    static let managerIdentifier = "Trio"
+    static let soundsDirectoryName = "Sounds"
+
+    @Injected() private var alertHistoryStorage: AlertHistoryStorage!
+
+    let muter: AlertMuter
+    private let throttler: AlertThrottler
+    private let soundLoader: AlertSoundLoader
+
+    let modalScheduler: TrioModalAlertScheduler
+    private let userNotificationScheduler: TrioUserNotificationAlertScheduler
+
+    private let queue = DispatchQueue(label: "BaseTrioAlertManager.queue")
+    private var responders: [String: WeakRef] = [:]
+    private var soundVendors: [String: WeakRef] = [:]
+    private var liveAlerts: [Alert.Identifier: Alert] = [:]
+
+    init(resolver: Resolver) {
+        muter = AlertMuter()
+        throttler = AlertThrottler()
+        let soundsRoot = FileManager.default
+            .urls(for: .libraryDirectory, in: .userDomainMask)
+            .last!
+            .appendingPathComponent(Self.soundsDirectoryName, isDirectory: true)
+        soundLoader = AlertSoundLoader(destination: soundsRoot)
+        modalScheduler = TrioModalAlertScheduler()
+        userNotificationScheduler = TrioUserNotificationAlertScheduler(
+            notificationCenter: UNUserNotificationCenter.current(),
+            soundsRoot: soundsRoot
+        )
+        injectServices(resolver)
+        modalScheduler.responder = self
+        userNotificationScheduler.responder = self
+    }
+
+    // MARK: - Issue / Retract
+
+    func issueAlert(_ alert: Alert) {
+        let category = TrioAlertClassifier.categorize(alertIdentifier: alert.identifier.alertIdentifier)
+        debug(
+            .service,
+            "TrioAlertManager.issueAlert \(alert.identifier.value) category=\(category) level=\(alert.interruptionLevel)"
+        )
+        guard category.isAlertWorthy else {
+            debug(.service, "TrioAlertManager dropped \(alert.identifier.value): \(category) not alert-worthy")
+            return
+        }
+        guard throttler.shouldDeliver(alert) else {
+            debug(.service, "TrioAlertManager throttled \(alert.identifier.value)")
+            return
+        }
+        queue.async {
+            self.liveAlerts[alert.identifier] = alert
+        }
+        recordIssued(alert)
+        let muted = muter.shouldMute(at: Date())
+        modalScheduler.schedule(alert)
+        userNotificationScheduler.schedule(
+            alert,
+            muted: muted,
+            soundURL: soundLoader.url(for: alert)
+        )
+    }
+
+    func retractAlert(identifier: Alert.Identifier) {
+        queue.async {
+            self.liveAlerts.removeValue(forKey: identifier)
+        }
+        modalScheduler.unschedule(identifier: identifier)
+        userNotificationScheduler.unschedule(identifier: identifier)
+        alertHistoryStorage.removeAlert(identifier: identifier.alertIdentifier)
+    }
+
+    // MARK: - Acknowledgement
+
+    func handleAcknowledgement(identifier: Alert.Identifier) {
+        modalScheduler.unschedule(identifier: identifier)
+        userNotificationScheduler.unschedule(identifier: identifier)
+        alertHistoryStorage.acknowledgeAlert(issuedDate(for: identifier) ?? Date(), nil)
+        queue.async {
+            self.liveAlerts.removeValue(forKey: identifier)
+        }
+        let responder = queue.sync { responders[identifier.managerIdentifier]?.ref as? AlertResponder }
+        responder?.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) { error in
+            if let error = error {
+                debug(.service, "AlertManager ack failed for \(identifier.value): \(error)")
+            }
+        }
+    }
+
+    func handleNotificationResponse(_ response: UNNotificationResponse) {
+        let userInfo = response.notification.request.content.userInfo
+        guard
+            let managerId = userInfo[AlertUserInfoKey.managerIdentifier.rawValue] as? String,
+            let alertId = userInfo[AlertUserInfoKey.alertIdentifier.rawValue] as? String
+        else { return }
+        let identifier = Alert.Identifier(managerIdentifier: managerId, alertIdentifier: alertId)
+        handleAcknowledgement(identifier: identifier)
+    }
+
+    func acknowledgeAllOutstanding() {
+        let outstanding = alertHistoryStorage.unacknowledgedAlertsWithinLast24Hours()
+        for entry in outstanding {
+            let identifier = Alert.Identifier(
+                managerIdentifier: entry.managerIdentifier,
+                alertIdentifier: entry.alertIdentifier
+            )
+            handleAcknowledgement(identifier: identifier)
+        }
+    }
+
+    // MARK: - Registry
+
+    func register(responder: AlertResponder, for managerIdentifier: String) {
+        queue.async {
+            self.responders[managerIdentifier] = WeakRef(ref: responder as AnyObject)
+        }
+    }
+
+    func register(soundVendor: AlertSoundVendor, for managerIdentifier: String) {
+        queue.async {
+            self.soundVendors[managerIdentifier] = WeakRef(ref: soundVendor as AnyObject)
+        }
+        soundLoader.copySounds(from: soundVendor, managerIdentifier: managerIdentifier)
+    }
+
+    func unregister(managerIdentifier: String) {
+        queue.async {
+            self.responders.removeValue(forKey: managerIdentifier)
+            self.soundVendors.removeValue(forKey: managerIdentifier)
+        }
+    }
+
+    func soundURL(for alert: Alert) -> URL? {
+        soundLoader.url(for: alert)
+    }
+
+    // MARK: - Persistence bridge
+
+    private func recordIssued(_ alert: Alert) {
+        let entry = AlertEntry(
+            alertIdentifier: alert.identifier.alertIdentifier,
+            primitiveInterruptionLevel: alert.interruptionLevel.storedValue as? Decimal,
+            issuedDate: Date(),
+            managerIdentifier: alert.identifier.managerIdentifier,
+            triggerType: alert.trigger.storedType,
+            triggerInterval: alert.trigger.storedInterval as? Decimal,
+            contentTitle: alert.foregroundContent?.title ?? alert.backgroundContent.title,
+            contentBody: alert.foregroundContent?.body ?? alert.backgroundContent.body
+        )
+        alertHistoryStorage.addAlert(entry)
+    }
+
+    private func issuedDate(for identifier: Alert.Identifier) -> Date? {
+        alertHistoryStorage.unacknowledgedAlertsWithinLast24Hours()
+            .first {
+                $0.managerIdentifier == identifier.managerIdentifier
+                    && $0.alertIdentifier == identifier.alertIdentifier
+            }?.issuedDate
+    }
+}
+
+extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAlertResponder {}
+
+enum AlertUserInfoKey: String {
+    case managerIdentifier = "trio.alert.managerIdentifier"
+    case alertIdentifier = "trio.alert.alertIdentifier"
+}
+
+// MARK: - Muter
+
+final class AlertMuter: ObservableObject {
+    @Published private(set) var startDate: Date?
+    @Published private(set) var duration: TimeInterval = 0
+
+    static let allowedDurations: [TimeInterval] = [
+        30 * 60,
+        60 * 60,
+        2 * 60 * 60,
+        4 * 60 * 60
+    ]
+
+    func mute(for duration: TimeInterval, from start: Date = Date()) {
+        startDate = start
+        self.duration = duration
+    }
+
+    func unmute() {
+        startDate = nil
+        duration = 0
+    }
+
+    func shouldMute(at date: Date) -> Bool {
+        guard let start = startDate else { return false }
+        return date >= start && date < start.addingTimeInterval(duration)
+    }
+
+    var endsAt: Date? {
+        guard let start = startDate else { return nil }
+        return start.addingTimeInterval(duration)
+    }
+}
+
+// MARK: - Throttler
+
+final class AlertThrottler {
+    private let queue = DispatchQueue(label: "AlertThrottler.queue")
+    private var lastDelivered: [Alert.Identifier: Date] = [:]
+    private let minimumInterval: TimeInterval = 5 * 60
+
+    func shouldDeliver(_ alert: Alert) -> Bool {
+        queue.sync {
+            let now = Date()
+            if let last = lastDelivered[alert.identifier], now.timeIntervalSince(last) < minimumInterval {
+                return false
+            }
+            lastDelivered[alert.identifier] = now
+            return true
+        }
+    }
+
+    func reset(identifier: Alert.Identifier) {
+        queue.sync { _ = lastDelivered.removeValue(forKey: identifier) }
+    }
+}
+
+// MARK: - Sound loader
+
+final class AlertSoundLoader {
+    private let destination: URL
+    private let fileManager: FileManager
+
+    init(destination: URL, fileManager: FileManager = .default) {
+        self.destination = destination
+        self.fileManager = fileManager
+        try? fileManager.createDirectory(at: destination, withIntermediateDirectories: true)
+    }
+
+    func copySounds(from vendor: AlertSoundVendor, managerIdentifier: String) {
+        guard let sourceBase = vendor.getSoundBaseURL() else { return }
+        let target = destination.appendingPathComponent(managerIdentifier, isDirectory: true)
+        try? fileManager.createDirectory(at: target, withIntermediateDirectories: true)
+        for sound in vendor.getSounds() {
+            guard let filename = sound.filename else { continue }
+            let source = sourceBase.appendingPathComponent(filename)
+            let dest = target.appendingPathComponent(filename)
+            guard fileManager.fileExists(atPath: source.path) else { continue }
+            if fileManager.fileExists(atPath: dest.path) { continue }
+            try? fileManager.copyItem(at: source, to: dest)
+        }
+    }
+
+    func url(for alert: Alert) -> URL? {
+        guard let filename = alert.sound?.filename else { return nil }
+        let url = destination
+            .appendingPathComponent(alert.identifier.managerIdentifier, isDirectory: true)
+            .appendingPathComponent(filename)
+        return fileManager.fileExists(atPath: url.path) ? url : nil
+    }
+}

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

@@ -0,0 +1,246 @@
+import Combine
+import Foundation
+import LoopKit
+import SwiftUI
+
+protocol TrioModalAlertResponder: AnyObject {
+    func handleAcknowledgement(identifier: LoopKit.Alert.Identifier)
+}
+
+final class TrioModalAlertScheduler: ObservableObject {
+    weak var responder: TrioModalAlertResponder?
+
+    @Published private(set) var active: [LoopKit.Alert] = []
+    private var pending: [LoopKit.Alert.Identifier: Timer] = [:]
+
+    func schedule(_ alert: LoopKit.Alert) {
+        DispatchQueue.main.async { [weak self] in self?.scheduleOnMain(alert) }
+    }
+
+    func unschedule(identifier: LoopKit.Alert.Identifier) {
+        DispatchQueue.main.async { [weak self] in self?.unscheduleOnMain(identifier: identifier) }
+    }
+
+    func acknowledge(identifier: LoopKit.Alert.Identifier) {
+        guard Thread.isMainThread else {
+            DispatchQueue.main.async { [weak self] in self?.acknowledge(identifier: identifier) }
+            return
+        }
+        responder?.handleAcknowledgement(identifier: identifier)
+        remove(identifier: identifier)
+    }
+
+    private func scheduleOnMain(_ alert: LoopKit.Alert) {
+        guard alert.foregroundContent != nil else { return }
+        switch alert.trigger {
+        case .immediate:
+            insert(alert)
+        case let .delayed(interval):
+            scheduleTimer(alert: alert, interval: interval, repeats: false)
+        case let .repeating(interval):
+            scheduleTimer(alert: alert, interval: interval, repeats: true)
+        }
+    }
+
+    private func unscheduleOnMain(identifier: LoopKit.Alert.Identifier) {
+        pending.removeValue(forKey: identifier)?.invalidate()
+        remove(identifier: identifier)
+    }
+
+    private func scheduleTimer(alert: LoopKit.Alert, interval: TimeInterval, repeats: Bool) {
+        if pending[alert.identifier] != nil { return }
+        let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: repeats) { [weak self] _ in
+            DispatchQueue.main.async {
+                self?.insert(alert)
+                if !repeats {
+                    self?.pending.removeValue(forKey: alert.identifier)
+                }
+            }
+        }
+        pending[alert.identifier] = timer
+    }
+
+    private func insert(_ alert: LoopKit.Alert) {
+        guard !active.contains(where: { $0.identifier == alert.identifier }) else { return }
+        active.append(alert)
+        sortByPriority()
+    }
+
+    private func remove(identifier: LoopKit.Alert.Identifier) {
+        active.removeAll { $0.identifier == identifier }
+    }
+
+    private func sortByPriority() {
+        active.sort { Self.priority(of: $0.interruptionLevel) > Self.priority(of: $1.interruptionLevel) }
+    }
+
+    private static func priority(of level: LoopKit.Alert.InterruptionLevel) -> Int {
+        switch level {
+        case .critical: return 2
+        case .timeSensitive: return 1
+        case .active: return 0
+        }
+    }
+}
+
+struct TrioAlertBanner: View {
+    let alert: LoopKit.Alert
+    let onTap: () -> Void
+    let onSwipeAway: () -> Void
+
+    @State private var presentedAt = Date()
+    @State private var dragOffset: CGFloat = 0
+
+    private var content: LoopKit.Alert.Content? { alert.foregroundContent }
+
+    private var symbolName: String {
+        switch alert.interruptionLevel {
+        case .critical: return "exclamationmark.octagon.fill"
+        case .timeSensitive: return "exclamationmark.triangle.fill"
+        case .active: return "info.circle.fill"
+        }
+    }
+
+    private var accent: Color {
+        switch alert.interruptionLevel {
+        case .critical: return .red
+        case .timeSensitive: return .orange
+        case .active: return .blue
+        }
+    }
+
+    var body: some View {
+        HStack(alignment: .top, spacing: 12) {
+            Image(systemName: symbolName)
+                .font(.system(size: 20, weight: .semibold))
+                .foregroundStyle(.white)
+                .frame(width: 38, height: 38)
+                .background(accent, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
+
+            VStack(alignment: .leading, spacing: 2) {
+                HStack(alignment: .firstTextBaseline) {
+                    if let title = content?.title, !title.isEmpty {
+                        Text(title)
+                            .font(.subheadline.weight(.semibold))
+                    }
+                    Spacer(minLength: 8)
+                    TimelineView(.periodic(from: Date(), by: 5)) { context in
+                        Text(relativeTimestamp(now: context.date))
+                            .font(.footnote)
+                            .foregroundStyle(.secondary)
+                    }
+                }
+                if let body = content?.body, !body.isEmpty {
+                    Text(body)
+                        .font(.subheadline)
+                        .fixedSize(horizontal: false, vertical: true)
+                }
+            }
+        }
+        .padding(12)
+        .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
+        .shadow(color: .black.opacity(0.18), radius: 12, y: 4)
+        .contentShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+        .offset(x: dragOffset)
+        .opacity(1 - min(abs(dragOffset) / 200, 0.6))
+        .gesture(
+            DragGesture(minimumDistance: 12)
+                .onChanged { value in
+                    let dx = value.translation.width
+                    dragOffset = abs(dx) > abs(value.translation.height) ? dx : 0
+                }
+                .onEnded { value in
+                    if abs(value.translation.width) > 80 {
+                        onSwipeAway()
+                    } else {
+                        withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+                            dragOffset = 0
+                        }
+                    }
+                }
+        )
+        .onTapGesture { onTap() }
+    }
+
+    private func relativeTimestamp(now: Date) -> String {
+        let elapsed = now.timeIntervalSince(presentedAt)
+        if elapsed < 5 {
+            return String(localized: "now")
+        } else if elapsed < 60 {
+            return String(format: String(localized: "%ds ago"), Int(elapsed))
+        } else if elapsed < 3600 {
+            return String(format: String(localized: "%dm ago"), Int(elapsed / 60))
+        } else {
+            return String(format: String(localized: "%dh ago"), Int(elapsed / 3600))
+        }
+    }
+}
+
+struct TrioAlertModifier: ViewModifier {
+    @ObservedObject var scheduler: TrioModalAlertScheduler
+    @State private var isExpanded = false
+
+    private static let maxStackedVisible = 3
+
+    func body(content: Content) -> some View {
+        ZStack(alignment: .top) {
+            content
+            if !scheduler.active.isEmpty {
+                bannerStack
+                    .padding(.horizontal, 16)
+                    .padding(.top, 8)
+                    .zIndex(1000)
+            }
+        }
+        .onChange(of: scheduler.active.count) { _, newCount in
+            if newCount <= 1 { isExpanded = false }
+        }
+        .animation(.spring(response: 0.45, dampingFraction: 0.85), value: scheduler.active.map(\.identifier.value))
+        .animation(.spring(response: 0.35, dampingFraction: 0.85), value: isExpanded)
+    }
+
+    @ViewBuilder  private var bannerStack: some View {
+        if isExpanded || scheduler.active.count <= 1 {
+            VStack(spacing: 8) {
+                ForEach(scheduler.active, id: \.identifier) { alert in
+                    TrioAlertBanner(
+                        alert: alert,
+                        onTap: { scheduler.acknowledge(identifier: alert.identifier) },
+                        onSwipeAway: { scheduler.acknowledge(identifier: alert.identifier) }
+                    )
+                    .transition(.move(edge: .top).combined(with: .opacity))
+                }
+            }
+        } else {
+            collapsedStack
+        }
+    }
+
+    private var collapsedStack: some View {
+        let visible = scheduler.active.prefix(Self.maxStackedVisible)
+        return ZStack(alignment: .top) {
+            ForEach(Array(visible.enumerated().reversed()), id: \.element.identifier) { index, alert in
+                TrioAlertBanner(
+                    alert: alert,
+                    onTap: {
+                        withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
+                            isExpanded = true
+                        }
+                    },
+                    onSwipeAway: { scheduler.acknowledge(identifier: alert.identifier) }
+                )
+                .scaleEffect(1 - CGFloat(index) * 0.04, anchor: .top)
+                .offset(y: CGFloat(index) * 14)
+                .opacity(1 - Double(index) * 0.25)
+                .allowsHitTesting(index == 0)
+                .zIndex(Double(Self.maxStackedVisible - index))
+            }
+        }
+    }
+}
+
+extension View {
+    func trioAlerts(_ scheduler: TrioModalAlertScheduler) -> some View {
+        modifier(TrioAlertModifier(scheduler: scheduler))
+    }
+}

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

@@ -0,0 +1,94 @@
+import Foundation
+import LoopKit
+import UserNotifications
+
+protocol TrioUserNotificationAlertResponder: AnyObject {
+    func handleAcknowledgement(identifier: Alert.Identifier)
+}
+
+final class TrioUserNotificationAlertScheduler {
+    weak var responder: TrioUserNotificationAlertResponder?
+
+    private let notificationCenter: UNUserNotificationCenter
+    private let soundsRoot: URL
+
+    init(notificationCenter: UNUserNotificationCenter, soundsRoot: URL) {
+        self.notificationCenter = notificationCenter
+        self.soundsRoot = soundsRoot
+    }
+
+    func schedule(_ alert: Alert, muted: Bool, soundURL: URL?) {
+        let request = makeRequest(alert: alert, muted: muted, soundURL: soundURL)
+        notificationCenter.add(request) { error in
+            if let error = error {
+                debug(.service, "UserNotificationAlertScheduler failed: \(error.localizedDescription)")
+            }
+        }
+    }
+
+    func unschedule(identifier: Alert.Identifier) {
+        notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.value])
+        notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value])
+    }
+
+    private func makeRequest(alert: Alert, muted: Bool, soundURL: URL?) -> UNNotificationRequest {
+        let content = UNMutableNotificationContent()
+        content.title = alert.backgroundContent.title
+        content.body = alert.backgroundContent.body
+        content.threadIdentifier = alert.identifier.managerIdentifier
+        content.userInfo = [
+            AlertUserInfoKey.managerIdentifier.rawValue: alert.identifier.managerIdentifier,
+            AlertUserInfoKey.alertIdentifier.rawValue: alert.identifier.alertIdentifier
+        ]
+        content.interruptionLevel = alert.interruptionLevel.unNotificationLevel
+        content.sound = sound(for: alert, muted: muted, soundURL: soundURL)
+
+        return UNNotificationRequest(
+            identifier: alert.identifier.value,
+            content: content,
+            trigger: alert.trigger.unTrigger
+        )
+    }
+
+    private func sound(for alert: Alert, muted: Bool, soundURL: URL?) -> UNNotificationSound? {
+        let isCritical = alert.interruptionLevel == .critical
+        if muted {
+            return isCritical ? .defaultCriticalSound(withAudioVolume: 0) : nil
+        }
+        switch alert.sound {
+        case .none,
+             .vibrate:
+            return isCritical ? .defaultCriticalSound(withAudioVolume: 0) : nil
+        case let .sound(name):
+            if let filename = soundURL?.lastPathComponent {
+                let unName = UNNotificationSoundName(rawValue: filename)
+                return isCritical ? .criticalSoundNamed(unName) : UNNotificationSound(named: unName)
+            }
+            let unName = UNNotificationSoundName(name)
+            return isCritical ? .criticalSoundNamed(unName) : UNNotificationSound(named: unName)
+        }
+    }
+}
+
+private extension Alert.InterruptionLevel {
+    var unNotificationLevel: UNNotificationInterruptionLevel {
+        switch self {
+        case .active: return .active
+        case .timeSensitive: return .timeSensitive
+        case .critical: return .critical
+        }
+    }
+}
+
+private extension Alert.Trigger {
+    var unTrigger: UNNotificationTrigger? {
+        switch self {
+        case .immediate:
+            return nil
+        case let .delayed(interval):
+            return UNTimeIntervalNotificationTrigger(timeInterval: max(interval, 1), repeats: false)
+        case let .repeating(interval):
+            return UNTimeIntervalNotificationTrigger(timeInterval: max(interval, 60), repeats: true)
+        }
+    }
+}

+ 18 - 83
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -36,11 +36,6 @@ protocol alertMessageNotificationObserver {
     func alertMessageNotification(_ message: MessageContent)
 }
 
-protocol pumpNotificationObserver {
-    func pumpNotification(alert: AlertEntry)
-    func pumpRemoveNotification()
-}
-
 // MARK: - SnoozeObserver Protocol
 
 protocol SnoozeObserver {
@@ -53,8 +48,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         case carbsRequiredNotification = "Trio.carbsRequiredNotification"
         case noLoopFirstNotification = "Trio.noLoopFirstNotification"
         case noLoopSecondNotification = "Trio.noLoopSecondNotification"
-        case bolusFailedNotification = "Trio.bolusFailedNotification"
-        case pumpNotification = "Trio.pumpNotification"
         case alertMessageNotification = "Trio.alertMessageNotification"
     }
 
@@ -64,6 +57,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var apsManager: APSManager!
     @Injected() private var router: Router!
+    @Injected() private var trioAlertManager: TrioAlertManager!
 
     @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
 
@@ -98,8 +92,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 .eraseToAnyPublisher()
 
         broadcaster.register(DeterminationObserver.self, observer: self)
-        broadcaster.register(BolusFailureObserver.self, observer: self)
-        broadcaster.register(pumpNotificationObserver.self, observer: self)
         broadcaster.register(alertMessageNotificationObserver.self, observer: self)
 //        requestNotificationPermissionsIfNeeded()
         Task {
@@ -189,7 +181,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
 
     private func notifyCarbsRequired(_ carbs: Int) {
         guard Decimal(carbs) >= settingsManager.settings.carbsRequiredThreshold,
-              settingsManager.settings.showCarbsRequiredBadge, settingsManager.settings.notificationsCarb else { return }
+              settingsManager.settings.showCarbsRequiredBadge else { return }
 
         var titles: [String] = []
 
@@ -249,28 +241,6 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         )
     }
 
-    private func notifyBolusFailure() {
-        let title = String(localized: "Bolus failed", comment: "Bolus failed")
-        let body = String(
-            localized:
-            "Bolus failed or inaccurate. Check pump history before repeating.",
-            comment: "Bolus failed or inaccurate. Check pump history before repeating."
-        )
-        let content = UNMutableNotificationContent()
-        content.title = title
-        content.body = body
-        content.sound = .default
-
-        addRequest(
-            identifier: .noLoopFirstNotification,
-            content: content,
-            deleteOld: true,
-            trigger: nil,
-            messageType: .error,
-            messageSubtype: .pump
-        )
-    }
-
     private func fetchGlucoseIDs() async throws -> [NSManagedObjectID] {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
@@ -502,8 +472,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
             action: action
         )
         var alertIdentifier = identifier.rawValue
-        alertIdentifier = identifier == .pumpNotification ? alertIdentifier + content
-            .title : (identifier == .alertMessageNotification ? alertIdentifier + content.body : alertIdentifier)
+        if identifier == .alertMessageNotification {
+            alertIdentifier += content.body
+        }
         if deleteOld {
             DispatchQueue.main.async {
                 self.notificationCenter.removeDeliveredNotifications(withIdentifiers: [alertIdentifier])
@@ -570,12 +541,6 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
             content.title = message.title
         }
         switch message.subtype {
-        case .pump:
-            if message.type == .info || message.type == .error {
-                identifier = Identifier.alertMessageNotification
-            } else {
-                identifier = .pumpNotification
-            }
         case .carb:
             identifier = .carbsRequiredNotification
         case .glucose:
@@ -584,8 +549,6 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
             if message.trigger != nil {
                 identifier = message.content.contains(String(firstInterval)) ? Identifier.noLoopFirstNotification : Identifier
                     .noLoopSecondNotification
-            } else {
-                identifier = Identifier.alertMessageNotification
             }
         default:
             identifier = .alertMessageNotification
@@ -612,40 +575,7 @@ extension BaseUserNotificationsManager: alertMessageNotificationObserver {
     }
 }
 
-extension BaseUserNotificationsManager: pumpNotificationObserver {
-    func pumpNotification(alert: AlertEntry) {
-        let content = UNMutableNotificationContent()
-        let alertUp = alert.alertIdentifier.uppercased()
-        let typeMessage: MessageType
-        if alertUp.contains("FAULT") || alertUp.contains("ERROR") {
-            content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue
-            typeMessage = .error
-        } else {
-            typeMessage = .warning
-            guard settingsManager.settings.notificationsPump else { return }
-        }
-        content.title = alert.contentTitle ?? "Unknown"
-        content.body = alert.contentBody ?? "Unknown"
-        content.sound = .default
-        addRequest(
-            identifier: .pumpNotification,
-            content: content,
-            deleteOld: true,
-            trigger: nil,
-            messageType: typeMessage,
-            messageSubtype: .pump,
-            action: .pumpConfig
-        )
-    }
-
-    func pumpRemoveNotification() {
-        let identifier: Identifier = .pumpNotification
-        DispatchQueue.main.async {
-            self.notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.rawValue])
-            self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue])
-        }
-    }
-
+extension BaseUserNotificationsManager {
     /// Removes all glucose notifications (delivered and pending).
     /// Must be called from the main thread. Safe to call from @MainActor contexts.
     @MainActor private func removeGlucoseNotifications() {
@@ -662,18 +592,17 @@ extension BaseUserNotificationsManager: DeterminationObserver {
     }
 }
 
-extension BaseUserNotificationsManager: BolusFailureObserver {
-    func bolusDidFail() {
-        notifyBolusFailure()
-    }
-}
-
 extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
     func userNotificationCenter(
         _: UNUserNotificationCenter,
-        willPresent _: UNNotification,
+        willPresent notification: UNNotification,
         withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
     ) {
+        let userInfo = notification.request.content.userInfo
+        if userInfo[AlertUserInfoKey.managerIdentifier.rawValue] is String {
+            completionHandler([.badge, .list])
+            return
+        }
         completionHandler([.banner, .badge, .sound, .list])
     }
 
@@ -686,6 +615,12 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
     ) {
         defer { completionHandler() }
 
+        let userInfo = response.notification.request.content.userInfo
+        if userInfo[AlertUserInfoKey.managerIdentifier.rawValue] is String {
+            trioAlertManager.handleNotificationResponse(response)
+            return
+        }
+
         // Handle quick snooze actions (from notification action buttons)
         if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) {
             Task { @MainActor [weak self] in