Преглед изворни кода

Alerts: launch quiet window, stale-timer guard, snooze suppresses pending banners

Deniz Cengiz пре 4 недеља
родитељ
комит
4d32b6b7f6

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

@@ -37,6 +37,18 @@ final class GlucoseAlertCoordinator: Injectable {
     /// at the threshold boundary.
     private static let recoveryMarginMgDL: Decimal = 5
 
+    /// Suppresses evaluations for a short window after launch. `firingAlertIDs`
+    /// isn't persisted across relaunches, so without this quiet window the
+    /// first reading after launch would re-fire any in-flight alarm whose
+    /// glucose is still past threshold. UN throttle dedupes the lock-screen
+    /// notification (5-min window), but the in-app banner can re-pop. Skipping
+    /// the first ~30s lets the picture settle.
+    private static let launchQuietWindow: TimeInterval = 30
+    private let launchedAt = Date()
+    private var isInLaunchQuietWindow: Bool {
+        Date().timeIntervalSince(launchedAt) < Self.launchQuietWindow
+    }
+
     init(resolver: Resolver) {
         injectServices(resolver)
         let store = GlucoseAlertsStore.shared
@@ -58,6 +70,7 @@ final class GlucoseAlertCoordinator: Injectable {
     // MARK: - Reading-based evaluation
 
     private func evaluateGlucoseAlarms() {
+        guard !isInLaunchQuietWindow else { return }
         let snapshot = alertsSnapshot
         let configuration = configurationSnapshot
         let now = Date()
@@ -117,6 +130,7 @@ final class GlucoseAlertCoordinator: Injectable {
     // MARK: - Forecast-based evaluation
 
     private func evaluateForecast(_ determination: Determination) {
+        guard !isInLaunchQuietWindow else { return }
         let snapshot = alertsSnapshot
         let configuration = configurationSnapshot
         let now = Date()

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

@@ -272,6 +272,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         if duration > 0 {
             muter.mute(for: duration)
             clearPendingNonCriticalNotifications()
+            modalScheduler.clearNonCriticalBanners()
         } else {
             muter.unmute()
         }
@@ -338,6 +339,14 @@ extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAle
             await self?.applySnooze(for: duration)
         }
     }
+
+    func isAlertActive(identifier: Alert.Identifier) -> Bool {
+        queue.sync { liveAlerts[identifier] != nil }
+    }
+
+    func isSnoozeActive(at date: Date) -> Bool {
+        muter.shouldMute(at: date)
+    }
 }
 
 enum AlertUserInfoKey: String {

+ 38 - 2
Trio/Sources/Services/Alerts/TrioModalAlertScheduler.swift

@@ -8,6 +8,15 @@ protocol TrioModalAlertResponder: AnyObject {
     /// Applies a global snooze for `duration` seconds — same path as the UN
     /// quick-action buttons + the Snooze module + Apple Watch action.
     func requestSnooze(duration: TimeInterval)
+    /// Returns true if the alert is still tracked by the manager. Used by
+    /// `.delayed` timers to skip stale fires after the app resumes from
+    /// suspension — the timer fires immediately on resume, and without this
+    /// gate a banner would appear for an already-retracted alert.
+    func isAlertActive(identifier: LoopKit.Alert.Identifier) -> Bool
+    /// True if a global snooze is active. Non-critical `.delayed`/`.repeating`
+    /// banners check this at fire time so a previously-scheduled banner
+    /// (e.g. Not-Looping at +20m) stays silent during the snooze window.
+    func isSnoozeActive(at date: Date) -> Bool
 }
 
 final class TrioModalAlertScheduler: ObservableObject {
@@ -33,6 +42,17 @@ final class TrioModalAlertScheduler: ObservableObject {
         remove(identifier: identifier)
     }
 
+    /// Drops every active non-critical banner. Called from `applySnooze` so
+    /// banners visible at the moment a global snooze starts disappear too —
+    /// otherwise they'd outlive the alarm they advertised.
+    func clearNonCriticalBanners() {
+        guard Thread.isMainThread else {
+            DispatchQueue.main.async { [weak self] in self?.clearNonCriticalBanners() }
+            return
+        }
+        active.removeAll { $0.interruptionLevel != .critical }
+    }
+
     func snooze(identifier: LoopKit.Alert.Identifier, duration: TimeInterval) {
         guard Thread.isMainThread else {
             DispatchQueue.main.async { [weak self] in
@@ -67,9 +87,25 @@ final class TrioModalAlertScheduler: ObservableObject {
         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 {
+                // Skip stale fires (e.g. iOS suspended the app for longer
+                // than `interval`; on resume the timer fires immediately
+                // even if the underlying alert was already retracted).
+                guard let self, self.responder?.isAlertActive(identifier: alert.identifier) ?? true else {
                     self?.pending.removeValue(forKey: alert.identifier)
+                    return
+                }
+                // Honor active global snooze for non-critical banners.
+                if alert.interruptionLevel != .critical,
+                   self.responder?.isSnoozeActive(at: Date()) == true
+                {
+                    if !repeats {
+                        self.pending.removeValue(forKey: alert.identifier)
+                    }
+                    return
+                }
+                self.insert(alert)
+                if !repeats {
+                    self.pending.removeValue(forKey: alert.identifier)
                 }
             }
         }