Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 88 additions & 33 deletions Corner/System/NotificationScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,24 @@ import AppKit
import UserNotifications
import os

/// Identificadores compartidos entre el scheduler (MainActor) y el delegado
/// (nonisolated): fuera de ambos para que los dos puedan leerlos.
private enum NotificationIDs {
static let category = "TASK_REMINDER"
static let completeAction = "COMPLETE_TASK"
}

/// Entrega local de las alarmas por tarea (SPEC §4.1.6). Solo actúa cuando el
/// sync con Recordatorios está apagado: con el sync activo, la alarma viaja en
/// el recordatorio y la entrega Apple (Mac + iPhone + Watch), y notificar aquí
/// duplicaría el aviso.
///
/// Todas las llamadas a UNUserNotificationCenter usan las variantes con
/// completion handler `@Sendable`, no las `async`: los tipos de
/// UserNotifications no son Sendable en SDKs anteriores y las variantes async
/// no compilan en Xcode 16 (ver DEVELOPMENT.md, compatibilidad de SDK).
@MainActor
final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate {
final class NotificationScheduler {
/// true cuando Recordatorios se encarga de avisar.
var isDeliveryDelegated: () -> Bool = { false }
/// Tocar la notificación abre el panel.
Expand All @@ -16,21 +28,29 @@ final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate {
private let store: TaskStore
private let center = UNUserNotificationCenter.current()
private let log = Logger(subsystem: "com.andreetorres.corner", category: "Notifications")
private static let categoryID = "TASK_REMINDER"
private static let completeActionID = "COMPLETE_TASK"
private var delegate: NotificationDelegate?
private var pendingRefresh = false

init(store: TaskStore) {
self.store = store
super.init()
center.delegate = self
let complete = UNNotificationAction(identifier: Self.completeActionID,

let complete = UNNotificationAction(identifier: NotificationIDs.completeAction,
title: String(localized: "Completar"),
options: [])
center.setNotificationCategories([
UNNotificationCategory(identifier: Self.categoryID, actions: [complete],
UNNotificationCategory(identifier: NotificationIDs.category, actions: [complete],
intentIdentifiers: [], options: [])
])

// El delegado vive aparte y es Sendable: el center lo invoca desde
// contexto nonisolated y aquí solo se reentra al MainActor.
let delegate = NotificationDelegate { [weak self] taskID, isCompleteAction in
Task { @MainActor in
self?.handleResponse(taskID: taskID, isCompleteAction: isCompleteAction)
}
}
self.delegate = delegate
center.delegate = delegate
}

/// Reprograma tras cambios; agrupa ráfagas de guardados.
Expand Down Expand Up @@ -59,12 +79,13 @@ final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate {
}

let calendar = Calendar.current
let log = self.log
for reminder in pending {
let content = UNMutableNotificationContent()
content.title = reminder.title
content.body = String(localized: "Recordatorio de Corner")
content.sound = .default
content.categoryIdentifier = Self.categoryID
content.categoryIdentifier = NotificationIDs.category
content.userInfo = ["taskID": reminder.taskID.uuidString]

let components = calendar.dateComponents([.year, .month, .day, .hour, .minute],
Expand All @@ -73,50 +94,84 @@ final class NotificationScheduler: NSObject, UNUserNotificationCenterDelegate {
identifier: reminder.taskID.uuidString,
content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: components, repeats: false))
do {
try await center.add(request)
} catch {
log.error("no se pudo programar la alarma: \(error, privacy: .public)")
center.add(request) { @Sendable error in
if let error {
log.error("no se pudo programar la alarma: \(error, privacy: .public)")
}
}
Comment on lines +93 to 97
}
log.info("alarmas programadas: \(pending.count)")
}

/// El permiso se pide solo cuando de verdad hay algo que notificar.
// MARK: - Permiso (solo cuando hay algo que notificar)

private func ensureAuthorization() async -> Bool {
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
switch await currentStatus() {
case .authorized, .provisional:
return true
case .notDetermined:
return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false
return await requestAuthorization()
default:
return false
}
}

// MARK: - Interacción con la notificación
/// Se extrae solo el estado (enum Sendable): UNNotificationSettings no
/// puede cruzar la frontera de actor.
private func currentStatus() async -> UNAuthorizationStatus {
await withCheckedContinuation { continuation in
center.getNotificationSettings { @Sendable settings in
continuation.resume(returning: settings.authorizationStatus)
}
}
}

nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
// Extraer valores Sendable antes de cruzar al MainActor: el userInfo
// es [AnyHashable: Any] y no puede viajar entre actores.
let raw = response.notification.request.content.userInfo["taskID"] as? String
let actionID = response.actionIdentifier
guard let id = raw.flatMap({ UUID(uuidString: $0) }) else { return }
await MainActor.run {
if actionID == Self.completeActionID {
if let task = store.task(withID: id), !task.isDone { store.toggle(task) }
} else {
onOpenPanel?()
private func requestAuthorization() async -> Bool {
await withCheckedContinuation { continuation in
center.requestAuthorization(options: [.alert, .sound]) { @Sendable granted, _ in
continuation.resume(returning: granted)
}
Comment on lines +125 to 129
}
}

// MARK: - Respuesta del usuario

private func handleResponse(taskID: UUID, isCompleteAction: Bool) {
if isCompleteAction {
if let task = store.task(withID: taskID), !task.isDone { store.toggle(task) }
} else {
onOpenPanel?()
}
}
}

/// Delegado nonisolated y Sendable: recibe los callbacks del sistema y
/// entrega solo valores Sendable (el UUID de la tarea y qué acción fue).
private final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate,
@unchecked Sendable {
private let onResponse: @Sendable (UUID, Bool) -> Void

init(onResponse: @escaping @Sendable (UUID, Bool) -> Void) {
self.onResponse = onResponse
super.init()
}

func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let raw = response.notification.request.content.userInfo["taskID"] as? String
let isCompleteAction = response.actionIdentifier == NotificationIDs.completeAction
if let id = raw.flatMap({ UUID(uuidString: $0) }) {
onResponse(id, isCompleteAction)
}
completionHandler()
}

/// Mostrar el aviso aunque Corner esté al frente.
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async
-> UNNotificationPresentationOptions {
[.banner, .sound]
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .sound])
}
}
Loading