// // ReminderScheduler.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import Foundation import SwiftData import UserNotifications import OSLog /// Keeps pending local notifications in sync with the schedules' reminder times. /// `reconcile()` is an idempotent full resync: it wipes every pending request with /// the app's `reminder-` identifier prefix and re-adds from the current cache — so /// deleted schedules drop out naturally, and repeated calls are harmless. Trigger /// math lives in the pure `ReminderPlanner`. @MainActor final class ReminderScheduler { private let container: ModelContainer private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "reminders") private var reconcileTask: Task? init(container: ModelContainer) { self.container = container } /// Coalesced entry point for cache-change storms: cancels any not-yet-run /// reconcile and schedules a fresh one after a short beat. func scheduleReconcile() { reconcileTask?.cancel() reconcileTask = Task { [weak self] in try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } await self?.reconcile() } } /// Full resync of pending reminder notifications against the schedule cache. func reconcile() async { let plans = ReminderPlanner.plans(schedules: currentSources()) let center = UNUserNotificationCenter.current() // Wipe-then-add: remove exactly our requests, then re-add the current set. let pending = await center.pendingNotificationRequests() let stale = pending.map(\.identifier).filter { $0.hasPrefix(ReminderPlanner.identifierPrefix) } if !stale.isEmpty { center.removePendingNotificationRequests(withIdentifiers: stale) } guard !plans.isEmpty else { return } // Ask permission lazily — only once there is actually something to remind // about, so users who never set reminders never see the prompt. guard await requestAuthorizationIfNeeded() else { log.info("reconcile: notifications not authorized — \(plans.count) reminder(s) not scheduled") return } for plan in plans { let content = UNMutableNotificationContent() content.title = plan.routineName content.body = "Scheduled workout — time to train." content.sound = .default let trigger = UNCalendarNotificationTrigger(dateMatching: plan.dateComponents, repeats: plan.repeats) let request = UNNotificationRequest(identifier: plan.identifier, content: content, trigger: trigger) do { try await center.add(request) } catch { log.error("reconcile: failed to add \(plan.identifier): \(error.localizedDescription)") } } log.info("reconcile: scheduled \(plans.count) reminder request(s)") } /// Requests `.alert`/`.sound` authorization when it has never been asked for; /// returns whether reminders can be delivered afterward. func requestAuthorizationIfNeeded() async -> Bool { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() switch settings.authorizationStatus { case .notDetermined: return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false case .authorized, .provisional, .ephemeral: return true case .denied: return false @unknown default: return false } } /// Snapshot the schedule cache into plain planner inputs. private func currentSources() -> [ReminderSource] { let schedules = (try? container.mainContext.fetch(FetchDescriptor())) ?? [] return schedules.map { schedule in ReminderSource(id: schedule.id, routineName: schedule.routineName, recurrence: schedule.recurrenceEnum, weekdays: schedule.weekdays, date: schedule.date, reminderMinutes: schedule.reminderMinutes) } } }