From fc6d5a91bd36f8e525e614f95be3a9f2b81edaae Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Mon, 1 Jun 2026 02:02:04 +0300 Subject: [PATCH] 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 --- KisaniCal/Managers/NotificationManager.swift | 68 ++++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index 1e8d93f..acef380 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -7,9 +7,9 @@ final class NotificationManager: NSObject, ObservableObject { @Published var isAuthorized = false private let center = UNUserNotificationCenter.current() - private let workoutPrefix = "kisani.workout.weekday" - private let checkInPrefix = "kisani.workout.checkin" - private let tasksId = "kisani.tasks.evening" + private let workoutPrefix = "kisani.workout.weekday" + private let checkInPrefix = "kisani.workout.checkin" + private let tasksId = "kisani.tasks.evening" override private init() { super.init() @@ -36,11 +36,13 @@ final class NotificationManager: NSObject, ObservableObject { // MARK: - Reschedule (call on foreground + state changes) + @MainActor func reschedule(workoutVM: WorkoutViewModel, taskVM: TaskViewModel) { - // Snapshot @MainActor-isolated values before the async boundary - let schedule = workoutVM.schedule - let programs = workoutVM.programs - let progress = workoutVM.progress + // Snapshot all @MainActor-isolated values before the async boundary + let schedule = workoutVM.schedule + let programs = workoutVM.programs + let progress = workoutVM.progress + let tasks = taskVM.tasks center.getNotificationSettings { [weak self] settings in guard let self else { return } @@ -48,7 +50,7 @@ final class NotificationManager: NSObject, ObservableObject { || settings.authorizationStatus == .provisional guard ok else { return } self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress) - self.scheduleTasks(taskVM) + self.scheduleTasks(snapshot: tasks) } } @@ -59,19 +61,18 @@ final class NotificationManager: NSObject, ObservableObject { let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] } center.removePendingNotificationRequests(withIdentifiers: existingIds) - let reminderOn = ud.object(forKey: "workoutReminderEnabled") as? Bool ?? true - let checkInOn = ud.object(forKey: "workoutCheckInEnabled") as? Bool ?? false - let rHour = ud.object(forKey: "workoutHour") as? Int ?? 18 - let rMinute = ud.object(forKey: "workoutMinute") as? Int ?? 0 - let cHour = ud.object(forKey: "checkInHour") as? Int ?? 19 - let cMinute = ud.object(forKey: "checkInMinute") as? Int ?? 30 - let todayWD = Calendar.current.component(.weekday, from: Date()) + let reminderOn = ud.object(forKey: "workoutReminderEnabled") as? Bool ?? true + let checkInOn = ud.object(forKey: "workoutCheckInEnabled") as? Bool ?? false + let rHour = ud.object(forKey: "workoutHour") as? Int ?? 18 + let rMinute = ud.object(forKey: "workoutMinute") as? Int ?? 0 + let cHour = ud.object(forKey: "checkInHour") as? Int ?? 19 + let cMinute = ud.object(forKey: "checkInMinute") as? Int ?? 30 + let todayWD = Calendar.current.component(.weekday, from: Date()) for (weekday, pid) in schedule { guard let program = programs.first(where: { $0.id == pid }) else { continue } let doneToday = weekday == todayWD && progress >= 1.0 - // Pre-workout reminder if reminderOn && !doneToday { let content = UNMutableNotificationContent() content.title = program.name @@ -87,7 +88,6 @@ final class NotificationManager: NSObject, ObservableObject { )) } - // Post-workout check-in if checkInOn { let content = UNMutableNotificationContent() content.title = "\(program.name) — check in" @@ -107,9 +107,8 @@ final class NotificationManager: NSObject, ObservableObject { // MARK: - Per-task reminders + evening check-in - private func scheduleTasks(_ taskVM: TaskViewModel) { - // Capture on the calling thread (main) before crossing into the async callback - let snapshot = taskVM.tasks + private func scheduleTasks(snapshot: [TaskItem]) { + let center = self.center center.getPendingNotificationRequests { [weak self] pending in guard let self else { return } @@ -117,8 +116,8 @@ final class NotificationManager: NSObject, ObservableObject { let oldTaskIds = pending .filter { $0.identifier.hasPrefix("kisani.task.") } .map { $0.identifier } - self.center.removePendingNotificationRequests(withIdentifiers: oldTaskIds) - self.center.removePendingNotificationRequests(withIdentifiers: [self.tasksId]) + center.removePendingNotificationRequests(withIdentifiers: oldTaskIds) + center.removePendingNotificationRequests(withIdentifiers: [self.tasksId]) let now = Date() let cal = Calendar.current @@ -137,20 +136,19 @@ final class NotificationManager: NSObject, ObservableObject { guard fireDate > now else { continue } let content = UNMutableNotificationContent() - content.title = task.title - content.body = self.notifBody(for: task) - content.sound = .default + content.title = task.title + content.body = self.notifBody(for: task) + content.sound = .default content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"] let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate) - let trigger = UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false) - self.center.add(UNNotificationRequest( + center.add(UNNotificationRequest( 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 guard count > 0 else { return } let ec = UNMutableNotificationContent() @@ -159,8 +157,11 @@ final class NotificationManager: NSObject, ObservableObject { ec.sound = .default ec.userInfo = ["deeplink": "tasks"] var eComps = DateComponents(); eComps.hour = 19; eComps.minute = 30 - let eTrigger = UNCalendarNotificationTrigger(dateMatching: eComps, repeats: true) - self.center.add(UNNotificationRequest(identifier: self.tasksId, content: ec, trigger: eTrigger)) + center.add(UNNotificationRequest( + identifier: self.tasksId, + content: ec, + trigger: UNCalendarNotificationTrigger(dateMatching: eComps, repeats: true) + )) } } @@ -177,8 +178,7 @@ final class NotificationManager: NSObject, ObservableObject { // MARK: - UNUserNotificationCenterDelegate extension NotificationManager: UNUserNotificationCenterDelegate { - // Show banners even when app is foregrounded - func userNotificationCenter( + nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void @@ -186,7 +186,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate { completionHandler([.banner, .sound, .list, .badge]) } - func userNotificationCenter( + nonisolated func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void