Files
iceglass/IceGlass/Managers/NotificationManager.swift
T
rzen 8f8f8b2755 Initial commit: IceGlass NHL game tracker
macOS menu bar app providing NHL game situational awareness with
league-wide scoreboard, dynamic polling, notifications with team
logos, and configurable display options.
2026-04-13 21:44:08 -04:00

176 lines
6.5 KiB
Swift

//
// 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<Int>()
/// Track which score changes we've already notified about (gameId-awayScore-homeScore)
private var scoreChangesSent = Set<String>()
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()
}
}