// // NotificationManager.swift // IceGlass // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import Foundation import UniformTypeIdentifiers import UserNotifications class NotificationManager: @unchecked Sendable { private let logger = IceGlassLogger( subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass", category: "NotificationManager" ) static let shared = NotificationManager() /// Track which game starts we've already notified about private var gameStartsSent = Set() /// Track which score changes we've already notified about (gameId-awayScore-homeScore) private var scoreChangesSent = Set() private init() { logger.info("Initializing") requestNotificationPermissions() } private func requestNotificationPermissions() { UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in guard let self = self else { return } self.logger.info("Current notification settings: \(settings.authorizationStatus.rawValue)") UNUserNotificationCenter.current().requestAuthorization( options: [.alert, .sound, .provisional, .badge] ) { [weak self] granted, error in guard let self = self else { return } if let error = error { self.logger.error("Failed to request notification permission: \(error.localizedDescription)") } if granted { self.logger.info("Notification permission granted") } else { self.logger.warning("Notification permission denied") } } } } // MARK: - Game Started func notifyGameStarted(_ game: Scoreboard.Game, bypassDedup: Bool = false) { if !bypassDedup { guard !gameStartsSent.contains(game.id) else { return } gameStartsSent.insert(game.id) } let content = UNMutableNotificationContent() content.title = "Game Started" content.body = "\(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)" content.sound = .default content.interruptionLevel = .active content.userInfo = ["url": game.gameCenterUrl] // Attach away team logo (visiting team at the other team's arena) if let attachment = teamLogoAttachment(for: game.awayTeam.abbrev) { content.attachments = [attachment] } let identifier = bypassDedup ? "game-start-test-\(UUID().uuidString)" : "game-start-\(game.id)" let request = UNNotificationRequest( identifier: identifier, content: content, trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) ) UNUserNotificationCenter.current().add(request) { [weak self] error in if let error = error { self?.logger.error("Error sending game start notification: \(error.localizedDescription)") } else { self?.logger.info("Game start notification sent for \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)") } } } // MARK: - Goal Scored func notifyGoalScored(_ game: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, bypassDedup: Bool = false) { let awayScore = game.awayTeam.score ?? 0 let homeScore = game.homeTeam.score ?? 0 let key = "\(game.id)-\(awayScore)-\(homeScore)" if !bypassDedup { guard !scoreChangesSent.contains(key) else { return } scoreChangesSent.insert(key) } let content = UNMutableNotificationContent() content.title = "\(scoringTeam.abbrev) Goal!" content.body = "\(game.awayTeam.abbrev) \(awayScore) : \(game.homeTeam.abbrev) \(homeScore)" content.sound = UNNotificationSound(named: UNNotificationSoundName("Glass")) content.interruptionLevel = .active content.userInfo = ["url": game.gameCenterUrl] // Attach scoring team's logo if let attachment = teamLogoAttachment(for: scoringTeam.abbrev) { content.attachments = [attachment] } let identifier = bypassDedup ? "goal-test-\(UUID().uuidString)" : "goal-\(key)" let request = UNNotificationRequest( identifier: identifier, content: content, trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false) ) UNUserNotificationCenter.current().add(request) { [weak self] error in if let error = error { self?.logger.error("Error sending goal notification: \(error.localizedDescription)") } else { self?.logger.info("Goal notification sent: \(scoringTeam.abbrev) scored, \(awayScore):\(homeScore)") } } } // MARK: - Team Logo Attachment /// Creates a UNNotificationAttachment from the bundled team logo PNG private func teamLogoAttachment(for teamAbbrev: String) -> UNNotificationAttachment? { // Look for PNG in the TeamLogos bundle directory guard let logoURL = Bundle.main.url(forResource: teamAbbrev, withExtension: "png", subdirectory: "TeamLogos") else { logger.debug("No logo found for \(teamAbbrev)") return nil } // UNNotificationAttachment needs its own copy in a temp directory let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("team-logos", isDirectory: true) try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let tempFile = tempDir.appendingPathComponent("\(teamAbbrev)-\(UUID().uuidString).png") do { try FileManager.default.copyItem(at: logoURL, to: tempFile) let attachment = try UNNotificationAttachment( identifier: "team-logo-\(teamAbbrev)", url: tempFile, options: [ UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier, UNNotificationAttachmentOptionsThumbnailHiddenKey: false ] ) return attachment } catch { logger.error("Failed to create logo attachment for \(teamAbbrev): \(error.localizedDescription)") return nil } } func resetForNewDay() { logger.debug("Resetting notification tracking for new day") gameStartsSent.removeAll() scoreChangesSent.removeAll() } }