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.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user