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

Snooze: unify across pipeline + in-app banner affordances

* Canonical snooze entry point moved to BaseTrioAlertManager.applySnooze:
  persists snoozeUntilDate, mutes AlertMuter, clears any pending
  non-critical UNs, clears legacy glucose UN, broadcasts SnoozeObserver.
  UserNotificationsManager.applySnooze now just forwards — single source
  of truth for every snooze surface (Snooze module, phone UN action,
  watch UN action, in-app banner).
* BaseTrioAlertManager.init rehydrates AlertMuter from the persisted
  snoozeUntilDate so a force-quit + relaunch during an active snooze
  still suppresses non-critical alerts.
* TrioUserNotificationAlertScheduler sets categoryIdentifier =
  NotificationCategoryIdentifier.trioAlert so the four quick-snooze
  action buttons (20m / 1h / 3h / 6h, registered by cachrisman's
  NotificationCategoryFactory) appear on every new-pipeline UN on
  both phone and watch.
* TrioAlertManager.clearPendingNonCriticalNotifications removes any
  already-scheduled non-critical UNs (e.g. the not-looping 20m
  delayed trigger) at snooze-start so they don't fire mid-snooze.
  Critical UNs stay — they pierce snooze by design.
* TrioAlertManager.retractAlert resets the throttler for the
  identifier so re-arming the same alarm isn't blocked by 5-min
  duplicate suppression.
* TrioModalAlertResponder gains requestSnooze(duration:);
  TrioModalAlertScheduler.snooze(identifier:duration:) forwards and
  removes the banner.
* TrioAlertBanner: explicit moon-zzz Menu button on the trailing edge
  (non-critical only) exposing the four NotificationResponseAction
  durations. Direction-aware swipe (right = ack, left = quick 20m
  snooze) and long-press context menu remain as alternates.
Deniz Cengiz пре 3 недеља
родитељ
комит
956405cfc0

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

@@ -15,6 +15,17 @@ protocol TrioAlertManager: AnyObject {
     func handleAcknowledgement(identifier: Alert.Identifier)
     func handleNotificationResponse(_ response: UNNotificationResponse)
     func acknowledgeAllOutstanding()
+    /// Canonical snooze entry point used by every snooze surface:
+    /// Snooze module, phone UN action, watch UN action, in-app banner.
+    /// Persists snoozeUntilDate, mutes AlertMuter, clears any pending
+    /// non-critical UNs, broadcasts `SnoozeObserver`.
+    @MainActor func applySnooze(for duration: TimeInterval) async
+    /// Removes pending + already-delivered non-critical user notifications
+    /// posted via the new alert pipeline. Used internally when a snooze
+    /// begins so previously-scheduled delayed alerts (e.g. not-looping)
+    /// don't fire during the snooze window. Critical UNs are left in
+    /// place — they pierce snooze by design.
+    func clearPendingNonCriticalNotifications()
 
     var muter: AlertMuter { get }
     var modalScheduler: TrioModalAlertScheduler { get }
@@ -31,6 +42,7 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
     static let soundsDirectoryName = "Sounds"
 
     @Injected() private var alertHistoryStorage: AlertHistoryStorage!
+    @Injected() private var broadcaster: Broadcaster!
 
     let muter: AlertMuter
     private let throttler: AlertThrottler
@@ -64,6 +76,14 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         injectServices(resolver)
         modalScheduler.responder = self
         userNotificationScheduler.responder = self
+        // Rehydrate the in-memory mute window from the persisted snoozeUntilDate
+        // so a force-quit + relaunch during an active snooze still suppresses
+        // non-critical alerts until the originally-chosen end time.
+        let persistedSnoozeUntil = UserDefaults.standard
+            .object(forKey: "UserNotificationsManager.snoozeUntilDate") as? Date
+        if let until = persistedSnoozeUntil, until > Date() {
+            muter.mute(for: until.timeIntervalSinceNow)
+        }
     }
 
     /// Falls back to in-process AVAudioPlayer for `.critical` alerts on
@@ -215,6 +235,52 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         }
     }
 
+    func clearPendingNonCriticalNotifications() {
+        let center = UNUserNotificationCenter.current()
+        center.getPendingNotificationRequests { requests in
+            let ids = requests
+                .filter { $0.content.interruptionLevel != .critical }
+                .map(\.identifier)
+            guard !ids.isEmpty else { return }
+            center.removePendingNotificationRequests(withIdentifiers: ids)
+        }
+        center.getDeliveredNotifications { delivered in
+            let ids = delivered
+                .filter { $0.request.content.interruptionLevel != .critical }
+                .map(\.request.identifier)
+            guard !ids.isEmpty else { return }
+            center.removeDeliveredNotifications(withIdentifiers: ids)
+        }
+    }
+
+    /// Persisted under the same key UN reads, so a force-quit + relaunch
+    /// during a snooze sees the same end-date.
+    private static let snoozeUntilDateKey = "UserNotificationsManager.snoozeUntilDate"
+    private static let legacyGlucoseNotificationID = "Trio.glucoseNotification"
+
+    @MainActor func applySnooze(for duration: TimeInterval) async {
+        let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
+        UserDefaults.standard.set(untilDate, forKey: Self.snoozeUntilDateKey)
+
+        // Legacy glucose-notification UN cleanup (the new pipeline uses
+        // per-alarm identifiers; this catches anything still lingering
+        // from older installs).
+        let center = UNUserNotificationCenter.current()
+        center.removeDeliveredNotifications(withIdentifiers: [Self.legacyGlucoseNotificationID])
+        center.removePendingNotificationRequests(withIdentifiers: [Self.legacyGlucoseNotificationID])
+
+        if duration > 0 {
+            muter.mute(for: duration)
+            clearPendingNonCriticalNotifications()
+        } else {
+            muter.unmute()
+        }
+
+        broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in
+            observer.snoozeDidChange(untilDate)
+        }
+    }
+
     // MARK: - Registry
 
     func register(responder: AlertResponder, for managerIdentifier: String) {
@@ -266,7 +332,13 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
     }
 }
 
-extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAlertResponder {}
+extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAlertResponder {
+    func requestSnooze(duration: TimeInterval) {
+        Task { @MainActor [weak self] in
+            await self?.applySnooze(for: duration)
+        }
+    }
+}
 
 enum AlertUserInfoKey: String {
     case managerIdentifier = "trio.alert.managerIdentifier"

+ 71 - 3
Trio/Sources/Services/Alerts/TrioModalAlertScheduler.swift

@@ -5,6 +5,9 @@ import SwiftUI
 
 protocol TrioModalAlertResponder: AnyObject {
     func handleAcknowledgement(identifier: LoopKit.Alert.Identifier)
+    /// 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)
 }
 
 final class TrioModalAlertScheduler: ObservableObject {
@@ -30,6 +33,19 @@ final class TrioModalAlertScheduler: ObservableObject {
         remove(identifier: identifier)
     }
 
+    func snooze(identifier: LoopKit.Alert.Identifier, duration: TimeInterval) {
+        guard Thread.isMainThread else {
+            DispatchQueue.main.async { [weak self] in
+                self?.snooze(identifier: identifier, duration: duration)
+            }
+            return
+        }
+        responder?.requestSnooze(duration: duration)
+        // Drop the in-app banner too — the global snooze mutes the queue,
+        // and leaving this specific banner hanging would be noise.
+        remove(identifier: identifier)
+    }
+
     private func scheduleOnMain(_ alert: LoopKit.Alert) {
         guard alert.foregroundContent != nil else { return }
         switch alert.trigger {
@@ -87,10 +103,13 @@ struct TrioAlertBanner: View {
     let alert: LoopKit.Alert
     let onTap: () -> Void
     let onSwipeAway: () -> Void
+    let onSnooze: (TimeInterval) -> Void
 
     @State private var presentedAt = Date()
     @State private var dragOffset: CGFloat = 0
 
+    private var canSnooze: Bool { alert.interruptionLevel != .critical }
+
     private var content: LoopKit.Alert.Content? { alert.foregroundContent }
 
     private var symbolName: String {
@@ -136,6 +155,25 @@ struct TrioAlertBanner: View {
                         .fixedSize(horizontal: false, vertical: true)
                 }
             }
+
+            if canSnooze {
+                Menu {
+                    ForEach(NotificationResponseAction.allCases, id: \.self) { action in
+                        Button {
+                            onSnooze(action.duration)
+                        } label: {
+                            Label(action.localizedTitle, systemImage: "moon.zzz")
+                        }
+                    }
+                } label: {
+                    Image(systemName: "moon.zzz.fill")
+                        .font(.title3)
+                        .foregroundStyle(.secondary)
+                        .frame(width: 32, height: 32)
+                        .contentShape(Rectangle())
+                }
+                .accessibilityLabel(Text("Snooze"))
+            }
         }
         .padding(12)
         .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
@@ -150,8 +188,14 @@ struct TrioAlertBanner: View {
                     dragOffset = abs(dx) > abs(value.translation.height) ? dx : 0
                 }
                 .onEnded { value in
-                    if abs(value.translation.width) > 80 {
+                    let dx = value.translation.width
+                    // Swipe right past threshold → dismiss / acknowledge.
+                    // Swipe left past threshold → quick snooze (20m) when
+                    // the alert isn't critical. Otherwise reset.
+                    if dx > 80 {
                         onSwipeAway()
+                    } else if dx < -80, canSnooze {
+                        onSnooze(NotificationResponseAction.snooze20.duration)
                     } else {
                         withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
                             dragOffset = 0
@@ -160,6 +204,24 @@ struct TrioAlertBanner: View {
                 }
         )
         .onTapGesture { onTap() }
+        .contextMenu {
+            if canSnooze {
+                Section(String(localized: "Snooze")) {
+                    ForEach(NotificationResponseAction.allCases, id: \.self) { action in
+                        Button {
+                            onSnooze(action.duration)
+                        } label: {
+                            Label(action.localizedTitle, systemImage: "moon.zzz")
+                        }
+                    }
+                }
+            }
+            Button {
+                onTap()
+            } label: {
+                Label(String(localized: "Acknowledge"), systemImage: "checkmark")
+            }
+        }
     }
 
     private func relativeTimestamp(now: Date) -> String {
@@ -206,7 +268,10 @@ struct TrioAlertModifier: ViewModifier {
                     TrioAlertBanner(
                         alert: alert,
                         onTap: { scheduler.acknowledge(identifier: alert.identifier) },
-                        onSwipeAway: { scheduler.acknowledge(identifier: alert.identifier) }
+                        onSwipeAway: { scheduler.acknowledge(identifier: alert.identifier) },
+                        onSnooze: { duration in
+                            scheduler.snooze(identifier: alert.identifier, duration: duration)
+                        }
                     )
                     .transition(.move(edge: .top).combined(with: .opacity))
                 }
@@ -227,7 +292,10 @@ struct TrioAlertModifier: ViewModifier {
                             isExpanded = true
                         }
                     },
-                    onSwipeAway: { scheduler.acknowledge(identifier: alert.identifier) }
+                    onSwipeAway: { scheduler.acknowledge(identifier: alert.identifier) },
+                    onSnooze: { duration in
+                        scheduler.snooze(identifier: alert.identifier, duration: duration)
+                    }
                 )
                 .scaleEffect(1 - CGFloat(index) * 0.04, anchor: .top)
                 .offset(y: CGFloat(index) * 14)

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

@@ -42,6 +42,12 @@ final class TrioUserNotificationAlertScheduler {
         ]
         content.interruptionLevel = alert.interruptionLevel.unNotificationLevel
         content.sound = sound(for: alert, muted: muted, soundURL: soundURL)
+        // Surface the four quick-snooze actions (20 min / 1 h / 3 h / 6 h)
+        // on both phone and watch lock-screen notifications. The category +
+        // its actions are registered by `NotificationCategoryFactory` on
+        // both the phone (`BaseUserNotificationsManager`) and watch
+        // (`WatchNotificationHandler`) UN delegates.
+        content.categoryIdentifier = NotificationCategoryIdentifier.trioAlert.rawValue
 
         return UNNotificationRequest(
             identifier: alert.identifier.value,

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

@@ -221,26 +221,12 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
+    /// Forwards to the canonical snooze entry point on `TrioAlertManager`.
+    /// All snooze surfaces (this method via UN actions / Watch / Snooze
+    /// module / in-app banner) converge there so persistent state, mute
+    /// window, and observers stay in sync.
     @MainActor func applySnooze(for duration: TimeInterval) async {
-        let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
-        snoozeUntilDate = untilDate
-        // removeGlucoseNotifications() is safe to call here since we're @MainActor
-        removeGlucoseNotifications()
-
-        // Mirror the snooze into the unified AlertMuter so non-critical
-        // LoopKit alerts (pump/cgm/algorithm) are suppressed during the
-        // same window. Critical alerts (e.g. glucose.urgentLow) pierce
-        // the mute in `TrioAlertManager.issueAlert`.
-        if duration > 0 {
-            trioAlertManager.muter.mute(for: duration)
-        } else {
-            trioAlertManager.muter.unmute()
-        }
-
-        // Notify observers that snooze was applied
-        broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in
-            observer.snoozeDidChange(untilDate)
-        }
+        await trioAlertManager.applySnooze(for: duration)
     }
 
     private func addRequest(