8f8f8b2755
macOS menu bar app providing NHL game situational awareness with league-wide scoreboard, dynamic polling, notifications with team logos, and configurable display options.
176 lines
6.5 KiB
Swift
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()
|
|
}
|
|
}
|