| class NotificationManager: ObservableObject {
static let shared = NotificationManager()
@Published var isAuthorized = false
private init() {
// Delay the authorization check until after init completes
DispatchQueue.main.async { [weak self] in
self?.checkAuthorizationStatus()
}
}
// MARK: - Authorization
func checkAuthorizationStatus() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
self.isAuthorized = settings.authorizationStatus == .authorized
}
}
}
func requestAuthorization() async -> Bool {
do {
let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
await MainActor.run {
self.isAuthorized = granted
}
return granted
} catch {
print("Error requesting notification authorization: \(error)")
return false
}
}
// MARK: - Scheduling Notifications
func scheduleReminders(for entry: SpotEntry) {
guard entry.remindersEnabled else { return }
// First cancel any existing reminders for this entry
cancelReminders(for: entry)
let now = Date()
for (index, interval) in SpotEntry.reminderIntervals.enumerated() {
let reminderDate = entry.dateTime.addingTimeInterval(-interval.seconds)
// Only schedule if the reminder time is in the future
guard reminderDate > now else { continue }
let content = UNMutableNotificationContent()
content.title = "🎭 \(entry.playName)"
content.body = reminderBodyText(intervalName: interval.name, cuisine: entry.cuisine)
content.sound = .default
content.userInfo = ["entryId": entry.id.uuidString]
let triggerDate = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: reminderDate
)
let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false)
let identifier = notificationIdentifier(for: entry, intervalIndex: index)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error scheduling notification: \(error)")
}
}
}
}
func cancelReminders(for entry: SpotEntry) {
let identifiers = SpotEntry.reminderIntervals.indices.map { index in
notificationIdentifier(for: entry, intervalIndex: index)
}
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: identifiers)
}
func cancelAllReminders(for entries: [SpotEntry]) {
for entry in entries {
cancelReminders(for: entry)
}
}
/// Called at app launch. Fetches the current pending notifications once and
/// only schedules reminders for entries that are missing them — avoids the
/// O(n × 8) cancel+reschedule storm that previously happened on every cold start.
func scheduleRemindersIfNeeded(for entries: [SpotEntry]) async {
let existing = await UNUserNotificationCenter.current().pendingNotificationRequests()
let existingIDs = Set(existing.map { $0.identifier })
for entry in entries where entry.remindersEnabled && entry.dateTime > Date() {
// Check whether every expected reminder already exists
let allPresent = SpotEntry.reminderIntervals.indices.allSatisfy { index in
existingIDs.contains(notificationIdentifier(for: entry, intervalIndex: index))
}
if !allPresent {
scheduleReminders(for: entry)
}
}
}
// MARK: - Helpers
private func notificationIdentifier(for entry: SpotEntry, intervalIndex: Int) -> String {
return "entry-\(entry.id.uuidString)-reminder-\(intervalIndex)"
}
private func reminderBodyText(intervalName: String, cuisine: String) -> String {
if cuisine.isEmpty {
return "Starting in \(intervalName)!"
} else {
return "Starting in \(intervalName) at \(cuisine)!"
}
}
// MARK: - Debug
func printPendingNotifications() {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
print("Pending notifications: \(requests.count)")
for request in requests {
print(" - \(request.identifier): \(request.content.title)")
}
}
}
} | `NotificationManager` class | Defines the `NotificationManager` class. Conforms to ObservableObject. |