From 7b23493f6fe837e4a96aef530471419bfe9e1acb Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Mon, 1 Jun 2026 01:54:20 +0300 Subject: [PATCH] =?UTF-8?q?Fix=20MainActor=20isolation=20in=20Notification?= =?UTF-8?q?Manager;=20var=E2=86=92let=20in=20AuthManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot WorkoutViewModel's schedule/programs/progress before the async getNotificationSettings callback so scheduleWorkout receives plain value types instead of MainActor-isolated properties. Also fix unused-mutation warning: email in handleApple changed to let. Co-Authored-By: Kutesir --- KisaniCal/Managers/AuthManager.swift | 2 +- KisaniCal/Managers/NotificationManager.swift | 93 +++++++++++++------- 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/KisaniCal/Managers/AuthManager.swift b/KisaniCal/Managers/AuthManager.swift index ae406e0..6ea7aa6 100644 --- a/KisaniCal/Managers/AuthManager.swift +++ b/KisaniCal/Managers/AuthManager.swift @@ -39,7 +39,7 @@ final class AuthManager: NSObject, ObservableObject { guard let cred = auth.credential as? ASAuthorizationAppleIDCredential else { return } // Apple only provides email + name on the very first sign-in — cache them - var email = cred.email ?? loadAppleEmail(for: cred.user) + let email = cred.email ?? loadAppleEmail(for: cred.user) if let e = cred.email { saveAppleEmail(e, for: cred.user) } let fullName = [cred.fullName?.givenName, cred.fullName?.familyName] diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index ccf1972..1e8d93f 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -7,8 +7,9 @@ final class NotificationManager: NSObject, ObservableObject { @Published var isAuthorized = false private let center = UNUserNotificationCenter.current() - private let workoutPrefix = "kisani.workout.weekday" - 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,55 +37,80 @@ final class NotificationManager: NSObject, ObservableObject { // MARK: - Reschedule (call on foreground + state changes) 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 + center.getNotificationSettings { [weak self] settings in guard let self else { return } let ok = settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional guard ok else { return } - self.scheduleWorkout(workoutVM) + self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress) self.scheduleTasks(taskVM) } } - // MARK: - Workout (7:00 PM on each scheduled weekday) + // MARK: - Workout reminders + check-in - private func scheduleWorkout(_ workoutVM: WorkoutViewModel) { - let existingIds = (1...7).map { "\(workoutPrefix).\($0)" } + private func scheduleWorkout(schedule: [Int: UUID], programs: [WorkoutProgram], progress: Double) { + let ud = UserDefaults.standard + let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] } center.removePendingNotificationRequests(withIdentifiers: existingIds) - let todayWeekday = 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 workoutVM.schedule { - guard let program = workoutVM.programs.first(where: { $0.id == pid }) else { continue } + for (weekday, pid) in schedule { + guard let program = programs.first(where: { $0.id == pid }) else { continue } + let doneToday = weekday == todayWD && progress >= 1.0 - // Today's workout is already done — skip so notification won't fire this evening - if weekday == todayWeekday && workoutVM.progress >= 1.0 { continue } + // Pre-workout reminder + if reminderOn && !doneToday { + let content = UNMutableNotificationContent() + content.title = program.name + content.body = "Time to work out — let's go!" + content.sound = .default + content.userInfo = ["deeplink": "workout"] + var comps = DateComponents() + comps.weekday = weekday; comps.hour = rHour; comps.minute = rMinute + center.add(UNNotificationRequest( + identifier: "\(workoutPrefix).\(weekday)", + content: content, + trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + )) + } - let content = UNMutableNotificationContent() - content.title = "\(program.emoji) Workout check-in" - content.body = "Did you finish your \(program.name)? Mark your sets complete." - content.sound = .default - content.userInfo = ["deeplink": "workout"] - - var comps = DateComponents() - comps.weekday = weekday - comps.hour = 19 - comps.minute = 0 - - let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) - let request = UNNotificationRequest( - identifier: "\(workoutPrefix).\(weekday)", - content: content, - trigger: trigger - ) - center.add(request) + // Post-workout check-in + if checkInOn { + let content = UNMutableNotificationContent() + content.title = "\(program.name) — check in" + content.body = doneToday ? "Great work today!" : "Did you finish? Mark your sets complete." + content.sound = .default + content.userInfo = ["deeplink": "workout"] + var comps = DateComponents() + comps.weekday = weekday; comps.hour = cHour; comps.minute = cMinute + center.add(UNNotificationRequest( + identifier: "\(checkInPrefix).\(weekday)", + content: content, + trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + )) + } } } // MARK: - Per-task reminders + evening check-in private func scheduleTasks(_ taskVM: TaskViewModel) { - // Fetch pending so we can cleanly remove all previous task notifications + // Capture on the calling thread (main) before crossing into the async callback + let snapshot = taskVM.tasks + center.getPendingNotificationRequests { [weak self] pending in guard let self else { return } @@ -97,10 +123,9 @@ final class NotificationManager: NSObject, ObservableObject { let now = Date() let cal = Calendar.current - for task in taskVM.tasks where !task.isComplete { + for task in snapshot where !task.isComplete { guard let due = task.dueDate else { continue } - // Determine fire time: exact if user specified a time, else 9:00 AM on due date let fireDate: Date if task.hasTime { fireDate = due @@ -126,7 +151,7 @@ final class NotificationManager: NSObject, ObservableObject { } // Evening urgent check-in - let count = taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count + let count = snapshot.filter { !$0.isComplete && $0.quadrant == .urgent }.count guard count > 0 else { return } let ec = UNMutableNotificationContent() ec.title = "Evening check-in" @@ -158,7 +183,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate { willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - completionHandler([.banner, .sound]) + completionHandler([.banner, .sound, .list, .badge]) } func userNotificationCenter(