Fix all NotificationManager concurrency warnings

- Remove @MainActor from class (caused all Sendable/isolation cascade)
- Keep @MainActor only on reschedule() where VM access is needed
- Snapshot tasks in reschedule() so scheduleTasks() never captures
  a non-Sendable TaskViewModel across async boundaries
- Capture center locally in scheduleTasks() to avoid @MainActor
  property access inside the @Sendable getPendingNotificationRequests callback
- Mark UNUserNotificationCenterDelegate methods nonisolated to satisfy
  protocol conformance without actor crossing

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
Robin Kutesa
2026-06-01 02:02:04 +03:00
parent 7b23493f6f
commit fc6d5a91bd

View File

@@ -36,11 +36,13 @@ final class NotificationManager: NSObject, ObservableObject {
// MARK: - Reschedule (call on foreground + state changes) // MARK: - Reschedule (call on foreground + state changes)
@MainActor
func reschedule(workoutVM: WorkoutViewModel, taskVM: TaskViewModel) { func reschedule(workoutVM: WorkoutViewModel, taskVM: TaskViewModel) {
// Snapshot @MainActor-isolated values before the async boundary // Snapshot all @MainActor-isolated values before the async boundary
let schedule = workoutVM.schedule let schedule = workoutVM.schedule
let programs = workoutVM.programs let programs = workoutVM.programs
let progress = workoutVM.progress let progress = workoutVM.progress
let tasks = taskVM.tasks
center.getNotificationSettings { [weak self] settings in center.getNotificationSettings { [weak self] settings in
guard let self else { return } guard let self else { return }
@@ -48,7 +50,7 @@ final class NotificationManager: NSObject, ObservableObject {
|| settings.authorizationStatus == .provisional || settings.authorizationStatus == .provisional
guard ok else { return } guard ok else { return }
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress) self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress)
self.scheduleTasks(taskVM) self.scheduleTasks(snapshot: tasks)
} }
} }
@@ -71,7 +73,6 @@ final class NotificationManager: NSObject, ObservableObject {
guard let program = programs.first(where: { $0.id == pid }) else { continue } guard let program = programs.first(where: { $0.id == pid }) else { continue }
let doneToday = weekday == todayWD && progress >= 1.0 let doneToday = weekday == todayWD && progress >= 1.0
// Pre-workout reminder
if reminderOn && !doneToday { if reminderOn && !doneToday {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = program.name content.title = program.name
@@ -87,7 +88,6 @@ final class NotificationManager: NSObject, ObservableObject {
)) ))
} }
// Post-workout check-in
if checkInOn { if checkInOn {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = "\(program.name) — check in" content.title = "\(program.name) — check in"
@@ -107,9 +107,8 @@ final class NotificationManager: NSObject, ObservableObject {
// MARK: - Per-task reminders + evening check-in // MARK: - Per-task reminders + evening check-in
private func scheduleTasks(_ taskVM: TaskViewModel) { private func scheduleTasks(snapshot: [TaskItem]) {
// Capture on the calling thread (main) before crossing into the async callback let center = self.center
let snapshot = taskVM.tasks
center.getPendingNotificationRequests { [weak self] pending in center.getPendingNotificationRequests { [weak self] pending in
guard let self else { return } guard let self else { return }
@@ -117,8 +116,8 @@ final class NotificationManager: NSObject, ObservableObject {
let oldTaskIds = pending let oldTaskIds = pending
.filter { $0.identifier.hasPrefix("kisani.task.") } .filter { $0.identifier.hasPrefix("kisani.task.") }
.map { $0.identifier } .map { $0.identifier }
self.center.removePendingNotificationRequests(withIdentifiers: oldTaskIds) center.removePendingNotificationRequests(withIdentifiers: oldTaskIds)
self.center.removePendingNotificationRequests(withIdentifiers: [self.tasksId]) center.removePendingNotificationRequests(withIdentifiers: [self.tasksId])
let now = Date() let now = Date()
let cal = Calendar.current let cal = Calendar.current
@@ -143,14 +142,13 @@ final class NotificationManager: NSObject, ObservableObject {
content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"] content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"]
let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate) let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate)
let trigger = UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false) center.add(UNNotificationRequest(
self.center.add(UNNotificationRequest(
identifier: "kisani.task.\(task.id.uuidString)", identifier: "kisani.task.\(task.id.uuidString)",
content: content, trigger: trigger content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false)
)) ))
} }
// Evening urgent check-in
let count = snapshot.filter { !$0.isComplete && $0.quadrant == .urgent }.count let count = snapshot.filter { !$0.isComplete && $0.quadrant == .urgent }.count
guard count > 0 else { return } guard count > 0 else { return }
let ec = UNMutableNotificationContent() let ec = UNMutableNotificationContent()
@@ -159,8 +157,11 @@ final class NotificationManager: NSObject, ObservableObject {
ec.sound = .default ec.sound = .default
ec.userInfo = ["deeplink": "tasks"] ec.userInfo = ["deeplink": "tasks"]
var eComps = DateComponents(); eComps.hour = 19; eComps.minute = 30 var eComps = DateComponents(); eComps.hour = 19; eComps.minute = 30
let eTrigger = UNCalendarNotificationTrigger(dateMatching: eComps, repeats: true) center.add(UNNotificationRequest(
self.center.add(UNNotificationRequest(identifier: self.tasksId, content: ec, trigger: eTrigger)) identifier: self.tasksId,
content: ec,
trigger: UNCalendarNotificationTrigger(dateMatching: eComps, repeats: true)
))
} }
} }
@@ -177,8 +178,7 @@ final class NotificationManager: NSObject, ObservableObject {
// MARK: - UNUserNotificationCenterDelegate // MARK: - UNUserNotificationCenterDelegate
extension NotificationManager: UNUserNotificationCenterDelegate { extension NotificationManager: UNUserNotificationCenterDelegate {
// Show banners even when app is foregrounded nonisolated func userNotificationCenter(
func userNotificationCenter(
_ center: UNUserNotificationCenter, _ center: UNUserNotificationCenter,
willPresent notification: UNNotification, willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
@@ -186,7 +186,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
completionHandler([.banner, .sound, .list, .badge]) completionHandler([.banner, .sound, .list, .badge])
} }
func userNotificationCenter( nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, _ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void withCompletionHandler completionHandler: @escaping () -> Void