Просмотр исходного кода

Alerts: per-type/category snooze, Snooze All header, fix coordinator CD crash, skip loop with no pump

Deniz Cengiz недель назад: 3
Родитель
Сommit
bbf4dd9a5f

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

@@ -274,6 +274,15 @@ final class BaseAPSManager: APSManager, Injectable {
     }
 
     private func canStartNewLoop() async -> Bool {
+        // Don't try to run a loop while pump setup / pod pairing is in
+        // progress — `verifyStatus` would throw `invalidPumpState("Pump not
+        // set")` and surface a modal banner on top of the pod activation
+        // sheet, closing the sheet (reported by tester during O5 pairing).
+        guard pumpManager != nil else {
+            debug(.apsManager, "No pump manager — skipping loop attempt")
+            return false
+        }
+
         // Check if too soon for next loop
         if lastLoopDate > lastLoopStartDate {
             guard lastLoopStartDate.addingTimeInterval(Config.loopInterval) < Date() else {

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

@@ -15,6 +15,17 @@ enum GlucoseAlertType: String, Codable, CaseIterable, Identifiable {
 
     var priority: Int { Self.allCases.firstIndex(of: self) ?? 0 }
 
+    /// Parses a glucose-alarm slug emitted by `GlucoseAlertCoordinator`
+    /// (`glucose.<type>.<uuid>`). Returns nil for non-glucose alert
+    /// identifiers — used by `BaseTrioAlertManager.requestSnooze` to decide
+    /// between per-type and global mute routing.
+    init?(slug: String) {
+        let parts = slug.split(separator: ".")
+        guard parts.count >= 2, parts[0] == "glucose" else { return nil }
+        guard let parsed = GlucoseAlertType(rawValue: String(parts[1])) else { return nil }
+        self = parsed
+    }
+
     var displayName: String {
         switch self {
         case .urgentLow: return String(localized: "Urgent Low Glucose")

+ 31 - 1
Trio/Sources/Services/Alerts/DeviceAlertsStore.swift

@@ -11,18 +11,25 @@ final class DeviceAlertsStore: ObservableObject {
     static let shared = DeviceAlertsStore()
 
     @Published var configs: [DeviceAlertSeverityConfig]
+    /// Per-category snooze expirations. Keyed by `PumpAlertCategory.rawValue`
+    /// for stable Codable round-tripping. `BaseTrioAlertManager.issueAlert`
+    /// drops alerts whose category has an entry with a future date.
+    @Published var categorySnoozes: [String: Date]
 
     private let defaults: UserDefaults
     private let configsKey: String
+    private let snoozesKey: String
 
     private var subscriptions = Set<AnyCancellable>()
 
     init(
         defaults: UserDefaults = .standard,
-        configsKey: String = "trio.deviceAlertSeverityConfigs.v1"
+        configsKey: String = "trio.deviceAlertSeverityConfigs.v1",
+        snoozesKey: String = "trio.deviceAlertCategorySnoozes.v1"
     ) {
         self.defaults = defaults
         self.configsKey = configsKey
+        self.snoozesKey = snoozesKey
         let loaded = Self.decode([DeviceAlertSeverityConfig].self, from: defaults, key: configsKey) ?? []
         // Guarantee one default `.always` per severity on first load. Any
         // missing severity gets a fresh seed so the lookup always finds a
@@ -34,15 +41,38 @@ final class DeviceAlertsStore: ObservableObject {
             seeded.append(DeviceAlertSeverityConfig(severity: severity, activeOption: .always))
         }
         configs = Self.sorted(seeded)
+        let snoozes = Self.decode([String: Date].self, from: defaults, key: snoozesKey) ?? [:]
+        // Prune expired entries on launch so the dictionary doesn't grow.
+        categorySnoozes = snoozes.filter { $0.value > Date() }
         bind()
     }
 
+    // MARK: - Per-category snooze
+
+    func snoozeCategory(_ category: PumpAlertCategory, until: Date) {
+        if until > Date() {
+            categorySnoozes[category.rawValue] = until
+        } else {
+            categorySnoozes.removeValue(forKey: category.rawValue)
+        }
+    }
+
+    func isCategorySnoozed(_ category: PumpAlertCategory, at date: Date) -> Bool {
+        guard let until = categorySnoozes[category.rawValue] else { return false }
+        return until > date
+    }
+
     private func bind() {
         $configs
             .dropFirst()
             .removeDuplicates()
             .sink { [weak self] value in self?.encode(value, to: self?.configsKey ?? "") }
             .store(in: &subscriptions)
+        $categorySnoozes
+            .dropFirst()
+            .removeDuplicates()
+            .sink { [weak self] value in self?.encode(value, to: self?.snoozesKey ?? "") }
+            .store(in: &subscriptions)
     }
 
     // MARK: - Lookup

+ 78 - 12
Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift

@@ -68,23 +68,42 @@ final class GlucoseAlertCoordinator: Injectable {
             .sink { [weak self] new in self?.configurationSnapshot = new }
             .store(in: &subscriptions)
         broadcaster.register(DeterminationObserver.self, observer: self)
+        broadcaster.register(SnoozeObserver.self, observer: self)
+        broadcaster.register(GlucoseSnoozeObserver.self, observer: self)
         glucoseStorage.updatePublisher
-            .receive(on: evaluationQueue)
-            .sink { [weak self] _ in self?.evaluateGlucoseAlarms() }
+            .sink { [weak self] _ in
+                Task { [weak self] in await self?.evaluateGlucoseAlarms() }
+            }
             .store(in: &subscriptions)
     }
 
     // MARK: - Reading-based evaluation
 
-    private func evaluateGlucoseAlarms() {
+    /// Two-stage: async-fetch the latest value (Core Data perform), then hop
+    /// onto `evaluationQueue` to mutate `firingAlertIDs` safely.
+    private func evaluateGlucoseAlarms() async {
         guard !isInLaunchQuietWindow else { return }
+        guard let latestValue = await fetchLatestReadingMgDL() else { return }
         let snapshot = alertsSnapshot
         let configuration = configurationSnapshot
         let now = Date()
+        evaluationQueue.async { [weak self] in
+            self?.applyReadingBasedEvaluation(
+                latestValue: latestValue,
+                snapshot: snapshot,
+                configuration: configuration,
+                now: now
+            )
+        }
+    }
 
-        guard let latest = fetchLatestReading() else { return }
-        let latestValue = Decimal(latest.glucose)
-
+    private func applyReadingBasedEvaluation(
+        latestValue: Decimal,
+        snapshot: [GlucoseAlert],
+        configuration: GlucoseAlertConfiguration,
+        now: Date
+    ) {
+        dispatchPrecondition(condition: .onQueue(evaluationQueue))
         // Iterate in type priority order so we can suppress lesser low-family
         // alarms when a more severe one is firing (urgent low > low). Without
         // this, glucose=60 with both urgent-low (54) AND low (70) configured
@@ -281,19 +300,26 @@ final class GlucoseAlertCoordinator: Injectable {
         }
     }
 
-    private func fetchLatestReading() -> GlucoseStored? {
+    /// Async fetch matching Trio's standard Core Data pattern — never blocks
+    /// the caller's thread, never lets a `GlucoseStored` managed object cross
+    /// queue boundaries (would otherwise trip
+    /// `_PFAssertSafeMultiThreadedAccess_impl`).
+    private func fetchLatestReadingMgDL() async -> Decimal? {
+        let cutoff = Date().addingTimeInterval(-Self.readingFreshnessWindow)
+        let predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
         do {
-            let cutoff = Date().addingTimeInterval(-Self.readingFreshnessWindow)
-            let predicate = NSPredicate(format: "date >= %@", cutoff as NSDate)
-            let results = try CoreDataStack.shared.fetchEntities(
+            let results = try await CoreDataStack.shared.fetchEntitiesAsync(
                 ofType: GlucoseStored.self,
                 onContext: coreDataContext,
                 predicate: predicate,
                 key: "date",
                 ascending: false,
                 fetchLimit: 1
-            ) as? [GlucoseStored]
-            return results?.first
+            )
+            return await coreDataContext.perform {
+                guard let latest = (results as? [GlucoseStored])?.first else { return nil }
+                return Decimal(latest.glucose)
+            }
         } catch {
             debug(.service, "GlucoseAlertCoordinator: glucose fetch failed: \(error)")
             return nil
@@ -308,3 +334,43 @@ extension GlucoseAlertCoordinator: DeterminationObserver {
         }
     }
 }
+
+extension GlucoseAlertCoordinator: SnoozeObserver {
+    /// Global snooze (Snooze module / lock-screen action). Clear all firing
+    /// IDs so the post-snooze evaluation can re-fire fresh if any condition
+    /// still breaches.
+    func snoozeDidChange(_ untilDate: Date) {
+        guard untilDate > Date() else { return }
+        evaluationQueue.async { [weak self] in
+            self?.firingAlertIDs.removeAll()
+        }
+    }
+}
+
+extension GlucoseAlertCoordinator: GlucoseSnoozeObserver {
+    /// Per-type snooze from an in-app banner tap / swipe / menu choice.
+    /// Stamps `snoozedUntil` on every matching `GlucoseAlert`, retracts every
+    /// in-flight alert of that type (so a stacked deck of multiple low
+    /// alarms dismisses as one unit), and drops matching entries from
+    /// `firingAlertIDs` so the next post-snooze reading evaluates fresh.
+    func snoozeGlucoseType(_ type: GlucoseAlertType, until untilDate: Date) {
+        DispatchQueue.main.async {
+            let store = GlucoseAlertsStore.shared
+            var updated = store.alerts
+            var matchingAlarms: [GlucoseAlert] = []
+            for index in updated.indices where updated[index].type == type {
+                updated[index].snoozedUntil = untilDate
+                matchingAlarms.append(updated[index])
+            }
+            guard !matchingAlarms.isEmpty else { return }
+            store.alerts = updated
+            for alarm in matchingAlarms {
+                self.trioAlertManager.retractAlert(identifier: self.alertID(for: alarm))
+            }
+            let ids = Set(matchingAlarms.map(\.id))
+            self.evaluationQueue.async { [weak self] in
+                self?.firingAlertIDs.subtract(ids)
+            }
+        }
+    }
+}

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

@@ -127,6 +127,13 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
         // If every variant in the matched tier is disabled, drop the alert.
         let effective: Alert
         if let pumpCategory = PumpAlertCategory(trioCategory: category) {
+            // Per-category snooze (set by in-app banner snooze) — drop new
+            // alerts in this category for the duration so a stacked deck
+            // doesn't re-pop while the user is still acting on it.
+            if DeviceAlertsStore.shared.isCategorySnoozed(pumpCategory, at: Date()) {
+                debug(.service, "TrioAlertManager dropped \(alert.identifier.value): category \(pumpCategory) snoozed")
+                return
+            }
             guard let configured = applyDeviceSeverityConfig(to: alert, category: pumpCategory) else {
                 debug(
                     .service,
@@ -334,6 +341,49 @@ final class BaseTrioAlertManager: TrioAlertManager, Injectable {
 }
 
 extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAlertResponder {
+    func requestSnooze(identifier: Alert.Identifier, duration: TimeInterval) {
+        let untilDate = duration > 0 ? Date().addingTimeInterval(duration) : .distantPast
+        if let glucoseType = GlucoseAlertType(slug: identifier.alertIdentifier) {
+            // Per-type snooze for glucose alarms — the coordinator stamps
+            // `snoozedUntil` on every matching alarm AND retracts each of
+            // their in-flight alerts, so a stacked deck of multiple lows
+            // dismisses together rather than one card at a time.
+            broadcaster.notify(GlucoseSnoozeObserver.self, on: .main) { (observer: GlucoseSnoozeObserver) in
+                observer.snoozeGlucoseType(glucoseType, until: untilDate)
+            }
+        } else if let pumpCategory = pumpCategory(forIdentifier: identifier) {
+            // Per-category snooze for device alarms — mirrors the glucose
+            // behavior. Records the category snooze on `DeviceAlertsStore`,
+            // retracts every in-flight alert in this category so any
+            // stacked siblings dismiss together.
+            DeviceAlertsStore.shared.snoozeCategory(pumpCategory, until: untilDate)
+            retractAlertsInCategory(pumpCategory)
+        } else {
+            // Unknown / fallback — global mute (Snooze module, lock-screen
+            // action, etc. take this path too).
+            Task { @MainActor [weak self] in
+                await self?.applySnooze(for: duration)
+            }
+        }
+    }
+
+    private func pumpCategory(forIdentifier identifier: Alert.Identifier) -> PumpAlertCategory? {
+        PumpAlertCategory(trioCategory: TrioAlertClassifier.categorize(alertIdentifier: identifier.alertIdentifier))
+    }
+
+    private func retractAlertsInCategory(_ category: PumpAlertCategory) {
+        let toRetract: [Alert.Identifier] = queue.sync {
+            liveAlerts.keys.filter { id in
+                pumpCategory(forIdentifier: id) == category
+            }
+        }
+        for id in toRetract {
+            retractAlert(identifier: id)
+        }
+    }
+
+    /// Convenience for the UN-action / Watch / Snooze-module paths that
+    /// don't have an originating alert identifier — they all want global.
     func requestSnooze(duration: TimeInterval) {
         Task { @MainActor [weak self] in
             await self?.applySnooze(for: duration)
@@ -349,6 +399,13 @@ extension BaseTrioAlertManager: TrioModalAlertResponder, TrioUserNotificationAle
     }
 }
 
+/// Observer for per-type glucose snoozes triggered from an in-app banner.
+/// `GlucoseAlertCoordinator` registers and applies the snooze to its
+/// `snoozedUntil` field on every matching `GlucoseAlert`.
+protocol GlucoseSnoozeObserver {
+    func snoozeGlucoseType(_ type: GlucoseAlertType, until: Date)
+}
+
 enum AlertUserInfoKey: String {
     case managerIdentifier = "trio.alert.managerIdentifier"
     case alertIdentifier = "trio.alert.alertIdentifier"

+ 119 - 30
Trio/Sources/Services/Alerts/TrioModalAlertScheduler.swift

@@ -5,9 +5,11 @@ 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)
+    /// Snooze the alert (and any siblings of its type). Glucose alarms route
+    /// to per-type suppression — snoozing a "low" silences all low alarms
+    /// for `duration` but leaves urgent-low / high / forecasted-low firing
+    /// normally. Pump / device alerts fall back to the global muter.
+    func requestSnooze(identifier: LoopKit.Alert.Identifier, 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
@@ -60,12 +62,28 @@ final class TrioModalAlertScheduler: ObservableObject {
             }
             return
         }
-        responder?.requestSnooze(duration: duration)
+        responder?.requestSnooze(identifier: identifier, 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)
     }
 
+    /// "Snooze all" entry point from the expanded stack header. Each alert
+    /// goes through its own routing — glucose per-type, device per-category,
+    /// everything else global — so a mixed stack snoozes correctly bucket by
+    /// bucket.
+    func snoozeAll(duration: TimeInterval) {
+        guard Thread.isMainThread else {
+            DispatchQueue.main.async { [weak self] in self?.snoozeAll(duration: duration) }
+            return
+        }
+        let identifiers = active.map(\.identifier)
+        for identifier in identifiers {
+            responder?.requestSnooze(identifier: identifier, duration: duration)
+        }
+        active.removeAll()
+    }
+
     private func scheduleOnMain(_ alert: LoopKit.Alert) {
         guard alert.foregroundContent != nil else { return }
         switch alert.trigger {
@@ -139,12 +157,28 @@ final class TrioModalAlertScheduler: ObservableObject {
 
 struct TrioAlertBanner: View {
     let alert: LoopKit.Alert
-    let onTap: () -> Void
+    /// Single-tap action — `nil` means tap behaves like swipe-up (fires the
+    /// 20-min snooze). The collapsed stack overrides this with "expand",
+    /// since tapping a stacked card should reveal the rest of the deck
+    /// before letting the user dismiss anything individually.
+    var onTap: (() -> Void)? = nil
+    /// Snooze path — fires from swipe-up + long-press menu + (when `onTap`
+    /// is nil) tap.
     let onSnooze: (TimeInterval) -> Void
 
     @State private var presentedAt = Date()
+    @State private var dragOffset: CGSize = .zero
+
+    private static let quickSnooze: TimeInterval = 20 * 60
 
-    private var canSnooze: Bool { alert.interruptionLevel != .critical }
+    /// Critical alerts and urgent-low glucose alarms are limited to the
+    /// 20-minute quick snooze — the safety floor. Other alerts get the full
+    /// 20m / 1h / 3h / 6h menu.
+    private var isQuickSnoozeOnly: Bool {
+        if alert.interruptionLevel == .critical { return true }
+        if GlucoseAlertType(slug: alert.identifier.alertIdentifier) == .urgentLow { return true }
+        return false
+    }
 
     private var content: LoopKit.Alert.Content? { alert.foregroundContent }
 
@@ -192,34 +226,62 @@ struct TrioAlertBanner: View {
                 }
             }
 
-            if canSnooze {
-                Menu {
-                    ForEach(NotificationResponseAction.allCases, id: \.self) { action in
-                        Button {
-                            onSnooze(action.duration)
-                        } label: {
-                            Label(action.localizedTitle, systemImage: "moon.zzz")
-                        }
+            Menu {
+                ForEach(snoozeOptions, 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"))
+            } 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))
         .shadow(color: .black.opacity(0.18), radius: 12, y: 4)
         .contentShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
-        .onTapGesture { onTap() }
+        .offset(y: min(0, dragOffset.height))
+        .opacity(1 - min(abs(dragOffset.height) / 200, 0.4))
+        .gesture(
+            // Swipe-up — iOS-banner gesture; tracks the drag visually then
+            // commits a 20-minute snooze past −50pt. Springs back otherwise.
+            DragGesture(minimumDistance: 8)
+                .onChanged { value in
+                    guard value.translation.height < 0 else { return }
+                    dragOffset = value.translation
+                }
+                .onEnded { value in
+                    if value.translation.height < -50 {
+                        onSnooze(Self.quickSnooze)
+                    } else {
+                        withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+                            dragOffset = .zero
+                        }
+                    }
+                }
+        )
+        .onTapGesture {
+            if let onTap { onTap() } else { onSnooze(Self.quickSnooze) }
+        }
         .contextMenu {
-            if canSnooze {
+            if isQuickSnoozeOnly {
+                // Critical + urgent-low: a single labeled action; no header,
+                // no other options.
+                Button {
+                    onSnooze(Self.quickSnooze)
+                } label: {
+                    Label(String(localized: "Snooze (20 min)"), systemImage: "moon.zzz")
+                }
+            } else {
                 Section(String(localized: "Snooze")) {
-                    ForEach(NotificationResponseAction.allCases, id: \.self) { action in
+                    ForEach(snoozeOptions, id: \.self) { action in
                         Button {
                             onSnooze(action.duration)
                         } label: {
@@ -228,14 +290,13 @@ struct TrioAlertBanner: View {
                     }
                 }
             }
-            Button {
-                onTap()
-            } label: {
-                Label(String(localized: "Acknowledge"), systemImage: "checkmark")
-            }
         }
     }
 
+    private var snoozeOptions: [NotificationResponseAction] {
+        isQuickSnoozeOnly ? [.snooze20] : NotificationResponseAction.allCases
+    }
+
     private func relativeTimestamp(now: Date) -> String {
         let elapsed = now.timeIntervalSince(presentedAt)
         if elapsed < 5 {
@@ -276,10 +337,12 @@ struct TrioAlertModifier: ViewModifier {
     @ViewBuilder private var bannerStack: some View {
         if isExpanded || scheduler.active.count <= 1 {
             VStack(spacing: 8) {
+                if scheduler.active.count > 1 {
+                    snoozeAllHeader
+                }
                 ForEach(scheduler.active, id: \.identifier) { alert in
                     TrioAlertBanner(
                         alert: alert,
-                        onTap: { scheduler.acknowledge(identifier: alert.identifier) },
                         onSnooze: { duration in
                             scheduler.snooze(identifier: alert.identifier, duration: duration)
                         }
@@ -292,6 +355,30 @@ struct TrioAlertModifier: ViewModifier {
         }
     }
 
+    /// iOS-style "clear all" affordance at the top of the expanded stack.
+    /// Runs every active alert through its own snooze routing — glucose
+    /// per-type, device per-category, etc.
+    private var snoozeAllHeader: some View {
+        HStack {
+            Spacer()
+            Button {
+                scheduler.snoozeAll(duration: 20 * 60)
+            } label: {
+                HStack(spacing: 4) {
+                    Text(String(localized: "Snooze all (20 min)"))
+                        .font(.footnote.weight(.semibold))
+                    Image(systemName: "moon.zzz.fill")
+                        .font(.footnote)
+                }
+                .foregroundStyle(.secondary)
+                .padding(.horizontal, 12)
+                .padding(.vertical, 6)
+                .background(.ultraThinMaterial, in: Capsule())
+            }
+            .buttonStyle(.plain)
+        }
+    }
+
     private var collapsedStack: some View {
         let visible = scheduler.active.prefix(Self.maxStackedVisible)
         return ZStack(alignment: .top) {
@@ -299,6 +386,8 @@ struct TrioAlertModifier: ViewModifier {
                 TrioAlertBanner(
                     alert: alert,
                     onTap: {
+                        // Tap on the collapsed deck expands it; individual
+                        // banners then own their own tap-to-snooze.
                         withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) {
                             isExpanded = true
                         }