Переглянути джерело

Implement snooze action for glucose notifications and enhance notification handling to eliminate duplicate notifications

Charlie Chrisman 4 місяців тому
батько
коміт
59772e0339

+ 101 - 0
Trio Watch App Extension/Helper/WatchNotificationHandler.swift

@@ -0,0 +1,101 @@
+import Foundation
+import UserNotifications
+import WatchConnectivity
+
+final class WatchNotificationHandler: NSObject, UNUserNotificationCenterDelegate {
+    static let shared = WatchNotificationHandler()
+
+    private override init() {
+        super.init()
+    }
+
+    func configure() {
+        let center = UNUserNotificationCenter.current()
+        center.delegate = self
+        registerCategories(on: center)
+    }
+
+    private func registerCategories(on center: UNUserNotificationCenter) {
+        center.getNotificationCategories { existingCategories in
+            let snoozeActions = NotificationResponseAction.allCases.map { action in
+                UNNotificationAction(
+                    identifier: action.rawValue,
+                    title: self.title(for: action),
+                    options: []
+                )
+            }
+
+            let glucoseCategory = UNNotificationCategory(
+                identifier: NotificationCategoryIdentifier.glucoseAlert.rawValue,
+                actions: snoozeActions,
+                intentIdentifiers: [],
+                options: []
+            )
+
+            var categories = existingCategories
+            categories.update(with: glucoseCategory)
+            center.setNotificationCategories(categories)
+        }
+    }
+
+    private func title(for action: NotificationResponseAction) -> String {
+        switch action {
+        case .snooze20:
+            return String(localized: "Snooze 20 min", comment: "Snooze glucose alerts for 20 minutes")
+        case .snooze40:
+            return String(localized: "Snooze 40 min", comment: "Snooze glucose alerts for 40 minutes")
+        case .snooze60:
+            return String(localized: "Snooze 1 hr", comment: "Snooze glucose alerts for 60 minutes")
+        }
+    }
+
+    func userNotificationCenter(
+        _: UNUserNotificationCenter,
+        didReceive response: UNNotificationResponse,
+        withCompletionHandler completionHandler: @escaping () -> Void
+    ) {
+        defer { completionHandler() }
+
+        guard let action = NotificationResponseAction(rawValue: response.actionIdentifier) else { return }
+        sendSnoozeRequest(for: action)
+    }
+
+    private func sendSnoozeRequest(for action: NotificationResponseAction) {
+        guard WCSession.isSupported() else { return }
+
+        let payload: [String: Any] = [WatchMessageKeys.snoozeDuration: action.minutes]
+        let session = WCSession.default
+
+        if session.delegate == nil {
+            session.delegate = PassiveSessionDelegate.shared
+        }
+
+        if session.activationState == .notActivated {
+            session.activate()
+        }
+
+        if session.isReachable {
+            session.sendMessage(payload, replyHandler: nil) { _ in
+                session.transferUserInfo(payload)
+            }
+        } else {
+            session.transferUserInfo(payload)
+        }
+    }
+}
+
+private final class PassiveSessionDelegate: NSObject, WCSessionDelegate {
+    static let shared = PassiveSessionDelegate()
+
+    private override init() {}
+
+    func session(
+        _: WCSession,
+        activationDidCompleteWith _: WCSessionActivationState,
+        error _: Error?
+    ) {}
+
+#if os(watchOS)
+    func sessionReachabilityDidChange(_: WCSession) {}
+#endif
+}

+ 5 - 0
Trio Watch App Extension/TrioWatchApp.swift

@@ -1,8 +1,13 @@
 import SwiftUI
+import UserNotifications
 
 @main struct TrioWatchApp: App {
     @Environment(\.scenePhase) private var scenePhase
 
+    init() {
+        WatchNotificationHandler.shared.configure()
+    }
+
     var body: some Scene {
         WindowGroup {
             TrioMainWatchView()

+ 26 - 0
Trio/Sources/Models/NotificationIdentifiers.swift

@@ -0,0 +1,26 @@
+import Foundation
+
+enum NotificationCategoryIdentifier: String {
+    case glucoseAlert = "Trio.glucoseAlert"
+}
+
+enum NotificationResponseAction: String, CaseIterable {
+    case snooze20 = "Trio.snooze20"
+    case snooze40 = "Trio.snooze40"
+    case snooze60 = "Trio.snooze60"
+
+    var duration: TimeInterval {
+        TimeInterval(minutes * 60)
+    }
+
+    var minutes: Int {
+        switch self {
+        case .snooze20:
+            return 20
+        case .snooze40:
+            return 40
+        case .snooze60:
+            return 60
+        }
+    }
+}

+ 3 - 0
Trio/Sources/Models/WatchMessageKeys.swift

@@ -51,4 +51,7 @@ enum WatchMessageKeys {
     static let maxProtein = "maxProtein"
     static let bolusIncrement = "bolusIncrement"
     static let confirmBolusFaster = "confirmBolusFaster"
+
+    // Notification Actions
+    static let snoozeDuration = "snoozeDuration"
 }

+ 18 - 0
Trio/Sources/Modules/Snooze/SnoozeStateModel.swift

@@ -11,5 +11,23 @@ extension Snooze {
         override func subscribe() {
             alarm = glucoseStogare.alarm
         }
+
+        // Add validation helper inside the class
+        private func validateSnoozeDuration(_ duration: TimeInterval) -> Bool {
+            // Only allow durations matching our defined actions
+            NotificationResponseAction.allCases
+                .map(\.duration)
+                .contains(duration)
+        }
+
+        // Add handleSnoozeResponse inside the class
+        func handleSnoozeResponse(_ duration: TimeInterval) {
+            guard validateSnoozeDuration(duration) else { return }
+
+            Task { @MainActor in
+                snoozeUntilDate = Date().addingTimeInterval(duration)
+                alarm = glucoseStogare.alarm
+            }
+        }
     }
 }

+ 96 - 0
Trio/Sources/Services/UserNotifications/UserNotificationsManager.swift

@@ -11,6 +11,7 @@ import UserNotifications
 protocol UserNotificationsManager {
     func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void)
     func requestNotificationPermissions(completion: @escaping (Bool) -> Void)
+    @MainActor func applySnooze(for duration: TimeInterval) async
 }
 
 enum GlucoseSourceKey: String {
@@ -40,6 +41,11 @@ protocol pumpNotificationObserver {
     func pumpRemoveNotification()
 }
 
+// MARK: - SnoozeObserver Protocol
+protocol SnoozeObserver {
+    func snoozeDidChange(_ untilDate: Date)
+}
+
 final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
     enum Identifier: String {
         case glucoseNotification = "Trio.glucoseNotification"
@@ -61,6 +67,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
     @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
 
     @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
+    // The glucose notification observers below (Core Data saves and the storage publisher) can fire for the same
+    // reading, so we persist the last alert token to avoid enqueueing identical high/low notifications multiple times.
+    @Persisted(key: "UserNotificationsManager.lastGlucoseAlertToken") private var lastGlucoseAlertToken: String = ""
 
     private let notificationCenter = UNUserNotificationCenter.current()
     private var lifetime = Lifetime()
@@ -95,11 +104,48 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         Task {
             await sendGlucoseNotification()
         }
+        configureNotificationCategories()
         registerHandlers()
         registerSubscribers()
         subscribeOnLoop()
     }
 
+    private func configureNotificationCategories() {
+        notificationCenter.getNotificationCategories { [weak self] existingCategories in
+            guard let self else { return }
+
+            let snoozeActions = NotificationResponseAction.allCases.map { action in
+                UNNotificationAction(
+                    identifier: action.rawValue,
+                    title: self.title(for: action),
+                    options: []
+                )
+            }
+
+            let glucoseCategory = UNNotificationCategory(
+                identifier: NotificationCategoryIdentifier.glucoseAlert.rawValue,
+                actions: snoozeActions,
+                intentIdentifiers: [],
+                options: []
+            )
+
+            var categories = existingCategories
+            categories.update(with: glucoseCategory)
+            self.notificationCenter.setNotificationCategories(categories)
+        }
+    }
+
+    private func title(for action: NotificationResponseAction) -> String {
+        switch action {
+        case .snooze20:
+            return String(localized: "Snooze 20 min", comment: "Snooze glucose alerts for 20 minutes")
+        case .snooze40:
+            return String(localized: "Snooze 40 min", comment: "Snooze glucose alerts for 40 minutes")
+        case .snooze60:
+            return String(localized: "Snooze 1 hr", comment: "Snooze glucose alerts for 60 minutes")
+        }
+    }
+
     private func subscribeOnLoop() {
         apsManager.lastLoopDateSubject
             .sink { [weak self] date in
@@ -271,6 +317,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 try viewContext.existingObject(with: id) as? GlucoseStored
             }
 
+            if glucoseStorage.alarm == .none {
+                lastGlucoseAlertToken = ""
+            }
+
             guard let lastReading = glucoseObjects.first?.glucose,
                   let secondLastReading = glucoseObjects.dropFirst().first?.glucose,
                   let lastDirection = glucoseObjects.first?.directionEnum?.symbol else { return }
@@ -305,6 +355,10 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 titles.append(String(localized: "(Snoozed)", comment: "(Snoozed)"))
                 notificationAlarm = false
             } else {
+                let token = alertToken(from: glucoseObjects.first)
+                if notificationAlarm, token == lastGlucoseAlertToken {
+                    return
+                }
                 titles.append(body)
                 let content = UNMutableNotificationContent()
                 content.title = titles.joined(separator: " ")
@@ -313,6 +367,7 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                 if notificationAlarm {
                     content.sound = .default
                     content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
+                    content.categoryIdentifier = NotificationCategoryIdentifier.glucoseAlert.rawValue
                 }
 
                 addRequest(
@@ -323,6 +378,9 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
                     messageSubtype: .glucose,
                     action: NotificationAction.snooze
                 )
+                if notificationAlarm {
+                    lastGlucoseAlertToken = token
+                }
             }
         } catch {
             debugPrint(
@@ -331,6 +389,18 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
+    private func alertToken(from glucose: GlucoseStored?) -> String {
+        if let id = glucose?.id?.uuidString {
+            return id
+        }
+
+        if let date = glucose?.date {
+            return "date-\(date.timeIntervalSince1970)"
+        }
+
+        return UUID().uuidString
+    }
+
     private func glucoseText(glucoseValue: Int, delta: Int?, direction: String?) -> String {
         let units = settingsManager.settings.units
         let glucoseText = glucoseFormatter
@@ -409,6 +479,18 @@ final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, In
         }
     }
 
+    @MainActor func applySnooze(for duration: TimeInterval) async {
+        let untilDate = Date().addingTimeInterval(duration)
+        snoozeUntilDate = untilDate
+        lastGlucoseAlertToken = ""
+        removeGlucoseNotifications()
+
+        // Notify observers that snooze was applied
+        broadcaster.notify(SnoozeObserver.self, on: .main) { (observer: SnoozeObserver) in
+            observer.snoozeDidChange(untilDate)
+        }
+    }
+
     private func addRequest(
         identifier: Identifier,
         content: UNMutableNotificationContent,
@@ -571,6 +653,12 @@ extension BaseUserNotificationsManager: pumpNotificationObserver {
             self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue])
         }
     }
+
+    private func removeGlucoseNotifications() {
+        let identifier = Identifier.glucoseNotification.rawValue
+        notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
+        notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
+    }
 }
 
 extension BaseUserNotificationsManager: DeterminationObserver {
@@ -601,6 +689,14 @@ extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
         withCompletionHandler completionHandler: @escaping () -> Void
     ) {
         defer { completionHandler() }
+
+        if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) {
+            Task { @MainActor in
+                await self.applySnooze(for: quickAction.duration)
+            }
+            return
+        }
+
         guard let actionRaw = response.notification.request.content.userInfo[NotificationAction.key] as? String,
               let action = NotificationAction(rawValue: actionRaw)
         else { return }

+ 11 - 3
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -25,6 +25,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
     @Injected() private var tempTargetStorage: TempTargetsStorage!
     @Injected() private var bolusCalculationManager: BolusCalculationManager!
     @Injected() private var iobService: IOBService!
+    @Injected() private var notificationsManager: UserNotificationsManager!
 
     private var units: GlucoseUnits = .mgdL
     private var glucoseColorScheme: GlucoseColorScheme = .staticColor
@@ -571,9 +572,16 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
                 return
             }
 
-            if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
-               message[WatchMessageKeys.carbs] == nil,
-               message[WatchMessageKeys.date] == nil
+            if let snoozeMinutes = message[WatchMessageKeys.snoozeDuration] as? Int {
+                debug(.watchManager, "📱 Received snooze request from watch: \(snoozeMinutes) minutes")
+                Task { @MainActor [weak self] in
+                    guard let self else { return }
+                    await self.notificationsManager.applySnooze(for: TimeInterval(snoozeMinutes * 60))
+                }
+                return
+            } else if let bolusAmount = message[WatchMessageKeys.bolus] as? Double,
+                      message[WatchMessageKeys.carbs] == nil,
+                      message[WatchMessageKeys.date] == nil
             {
                 debug(.watchManager, "📱 Received bolus request from watch: \(bolusAmount)U")
                 self?.handleBolusRequest(Decimal(bolusAmount))