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:
@@ -7,9 +7,9 @@ final class NotificationManager: NSObject, ObservableObject {
|
|||||||
@Published var isAuthorized = false
|
@Published var isAuthorized = false
|
||||||
|
|
||||||
private let center = UNUserNotificationCenter.current()
|
private let center = UNUserNotificationCenter.current()
|
||||||
private let workoutPrefix = "kisani.workout.weekday"
|
private let workoutPrefix = "kisani.workout.weekday"
|
||||||
private let checkInPrefix = "kisani.workout.checkin"
|
private let checkInPrefix = "kisani.workout.checkin"
|
||||||
private let tasksId = "kisani.tasks.evening"
|
private let tasksId = "kisani.tasks.evening"
|
||||||
|
|
||||||
override private init() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,19 +61,18 @@ final class NotificationManager: NSObject, ObservableObject {
|
|||||||
let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] }
|
let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] }
|
||||||
center.removePendingNotificationRequests(withIdentifiers: existingIds)
|
center.removePendingNotificationRequests(withIdentifiers: existingIds)
|
||||||
|
|
||||||
let reminderOn = ud.object(forKey: "workoutReminderEnabled") as? Bool ?? true
|
let reminderOn = ud.object(forKey: "workoutReminderEnabled") as? Bool ?? true
|
||||||
let checkInOn = ud.object(forKey: "workoutCheckInEnabled") as? Bool ?? false
|
let checkInOn = ud.object(forKey: "workoutCheckInEnabled") as? Bool ?? false
|
||||||
let rHour = ud.object(forKey: "workoutHour") as? Int ?? 18
|
let rHour = ud.object(forKey: "workoutHour") as? Int ?? 18
|
||||||
let rMinute = ud.object(forKey: "workoutMinute") as? Int ?? 0
|
let rMinute = ud.object(forKey: "workoutMinute") as? Int ?? 0
|
||||||
let cHour = ud.object(forKey: "checkInHour") as? Int ?? 19
|
let cHour = ud.object(forKey: "checkInHour") as? Int ?? 19
|
||||||
let cMinute = ud.object(forKey: "checkInMinute") as? Int ?? 30
|
let cMinute = ud.object(forKey: "checkInMinute") as? Int ?? 30
|
||||||
let todayWD = Calendar.current.component(.weekday, from: Date())
|
let todayWD = Calendar.current.component(.weekday, from: Date())
|
||||||
|
|
||||||
for (weekday, pid) in schedule {
|
for (weekday, pid) in schedule {
|
||||||
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
|
||||||
@@ -137,20 +136,19 @@ final class NotificationManager: NSObject, ObservableObject {
|
|||||||
guard fireDate > now else { continue }
|
guard fireDate > now else { continue }
|
||||||
|
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = task.title
|
content.title = task.title
|
||||||
content.body = self.notifBody(for: task)
|
content.body = self.notifBody(for: task)
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user