d642bffcaa
- SettingsView: replace broken Toggle+constant with a Button that directly opens system notification settings (Toggle was bound to .constant(true) and onChange never fired) - HTTPClient: append query parameters to URL for GET requests instead of putting them in the request body (body is ignored for GET) - PushNotificationService: document why info-severity alerts skip Live Activities - NotificationsViewModel: document polling page-reset behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
144 lines
5.4 KiB
Swift
144 lines
5.4 KiB
Swift
import Foundation
|
|
import UserNotifications
|
|
import UIKit
|
|
import ActivityKit
|
|
|
|
@MainActor
|
|
class PushNotificationService: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
|
|
static let shared = PushNotificationService()
|
|
|
|
@Published var deviceToken: String?
|
|
|
|
override private init() {
|
|
super.init()
|
|
UNUserNotificationCenter.current().delegate = self
|
|
}
|
|
|
|
func requestPermission() async -> Bool {
|
|
do {
|
|
let granted = try await UNUserNotificationCenter.current()
|
|
.requestAuthorization(options: [.alert, .badge, .sound])
|
|
return granted
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func registerForRemoteNotifications() {
|
|
UIApplication.shared.registerForRemoteNotifications()
|
|
}
|
|
|
|
func handleDeviceToken(_ tokenData: Data) {
|
|
let token = tokenData.map { String(format: "%02.2hhx", $0) }.joined()
|
|
deviceToken = token
|
|
Task {
|
|
try? await HTTPClient.shared.request(.registerDevice(token: token)) as EmptyResponse
|
|
}
|
|
}
|
|
|
|
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) async {
|
|
guard let aps = userInfo["aps"] as? [String: Any] else { return }
|
|
|
|
// Handle Live Activity push
|
|
if let event = aps["event"] as? String {
|
|
await handleLiveActivityPush(event: event, userInfo: userInfo, aps: aps)
|
|
return
|
|
}
|
|
|
|
// Update badge
|
|
if let badge = aps["badge"] as? Int {
|
|
await UIApplication.shared.setApplicationIconBadgeNumber(badge)
|
|
}
|
|
}
|
|
|
|
private func handleLiveActivityPush(event: String, userInfo: [AnyHashable: Any], aps: [String: Any]) async {
|
|
guard let contentStateData = aps["content-state"] as? [String: Any],
|
|
let contentStateJSON = try? JSONSerialization.data(withJSONObject: contentStateData),
|
|
let contentState = try? JSONDecoder.iso8601.decode(AlertAttributes.ContentState.self, from: contentStateJSON)
|
|
else { return }
|
|
|
|
switch event {
|
|
case "start":
|
|
await startLiveActivity(userInfo: userInfo, contentState: contentState)
|
|
case "update":
|
|
await updateLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState)
|
|
case "end":
|
|
await endLiveActivity(alertId: userInfo["alertId"] as? String, contentState: contentState)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func startLiveActivity(userInfo: [AnyHashable: Any], contentState: AlertAttributes.ContentState) async {
|
|
guard let attributes = userInfo["attributes"] as? [String: Any],
|
|
let topic = attributes["topic"] as? String,
|
|
let alertId = attributes["alertId"] as? String,
|
|
let severityStr = attributes["severity"] as? String,
|
|
let severity = Severity(rawValue: severityStr) else { return }
|
|
|
|
// Info-level alerts don't warrant a persistent Live Activity — they are low-priority
|
|
// and should only appear as a standard banner notification.
|
|
guard severity != .info else { return }
|
|
|
|
// Limit to 3 concurrent activities
|
|
let currentActivities = Activity<AlertAttributes>.activities
|
|
if currentActivities.count >= 3 {
|
|
// End the oldest
|
|
if let oldest = currentActivities.min(by: {
|
|
$0.contentState.startedAt < $1.contentState.startedAt
|
|
}) {
|
|
await oldest.end(ActivityContent(state: oldest.contentState, staleDate: nil), dismissalPolicy: .immediate)
|
|
}
|
|
}
|
|
|
|
let attrs = AlertAttributes(topic: topic, alertId: alertId, severity: severity)
|
|
_ = try? Activity<AlertAttributes>.request(
|
|
attributes: attrs,
|
|
content: ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600))
|
|
)
|
|
}
|
|
|
|
private func updateLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async {
|
|
guard let alertId else { return }
|
|
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == alertId {
|
|
await activity.update(ActivityContent(state: contentState, staleDate: Date().addingTimeInterval(4 * 3600)))
|
|
}
|
|
}
|
|
|
|
private func endLiveActivity(alertId: String?, contentState: AlertAttributes.ContentState) async {
|
|
guard let alertId else { return }
|
|
for activity in Activity<AlertAttributes>.activities where activity.attributes.alertId == alertId {
|
|
let dismissDate = Date().addingTimeInterval(5 * 60)
|
|
await activity.end(
|
|
ActivityContent(state: contentState, staleDate: dismissDate),
|
|
dismissalPolicy: .after(dismissDate)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - UNUserNotificationCenterDelegate
|
|
func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
willPresent notification: UNNotification,
|
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
) {
|
|
completionHandler([.banner, .badge, .sound])
|
|
}
|
|
|
|
func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
didReceive response: UNNotificationResponse,
|
|
withCompletionHandler completionHandler: @escaping () -> Void
|
|
) {
|
|
completionHandler()
|
|
}
|
|
}
|
|
|
|
extension JSONDecoder {
|
|
static let iso8601: JSONDecoder = {
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
return decoder
|
|
}()
|
|
}
|