Files
iceglass/IceGlass/Managers/NotificationManager.swift
rzen 18c4ef64d6 Add game-ended notification
Fires the first time a game transitions from non-over to any over-state
(OVER/FINAL/OFF), so the notification lands when the clock runs out
rather than hours later when NHL statisticians stamp OFF. Shows the
winning team's logo with a "{TEAM} wins" title and final score body.

Deduped by game ID via gameEndsSent; cleared on resetForNewDay.
Dev menu: "Test Game Ended Notification" for manual trigger.
2026-04-19 21:38:08 -04:00

350 lines
13 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// NotificationManager.swift
// IceGlass
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import CoreGraphics
import Foundation
import ImageIO
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>()
/// Track which game endings we've already notified about
private var gameEndsSent = Set<Int>()
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,
scorer: GoalScorer? = nil,
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!"
if let scorer = scorer {
content.subtitle = scorer.displayLine
}
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: - Game Ended
func notifyGameEnded(_ game: Scoreboard.Game, bypassDedup: Bool = false) {
if !bypassDedup {
guard !gameEndsSent.contains(game.id) else { return }
gameEndsSent.insert(game.id)
}
let awayScore = game.awayTeam.score ?? 0
let homeScore = game.homeTeam.score ?? 0
let winner: Scoreboard.Game.Team
let loser: Scoreboard.Game.Team
if awayScore > homeScore {
winner = game.awayTeam
loser = game.homeTeam
} else if homeScore > awayScore {
winner = game.homeTeam
loser = game.awayTeam
} else {
// Tie shouldn't happen in NHL, but handle gracefully
winner = game.homeTeam
loser = game.awayTeam
}
let winnerScore = max(awayScore, homeScore)
let loserScore = min(awayScore, homeScore)
let content = UNMutableNotificationContent()
content.title = "\(winner.abbrev) wins"
content.body = "\(winner.abbrev) \(winnerScore)\(loser.abbrev) \(loserScore)"
content.sound = .default
content.interruptionLevel = .active
content.userInfo = ["url": game.gameCenterUrl]
// Attach winning team's logo
if let attachment = teamLogoAttachment(for: winner.abbrev) {
content.attachments = [attachment]
}
let identifier = bypassDedup
? "game-end-test-\(UUID().uuidString)"
: "game-end-\(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 end notification: \(error.localizedDescription)")
} else {
self?.logger.info("Game end notification sent: \(winner.abbrev) \(winnerScore)\(loser.abbrev) \(loserScore)")
}
}
}
// MARK: - Team Logo Attachment
/// Creates a UNNotificationAttachment from the bundled team logo PNG.
/// PNGs live on the filesystem (TeamLogos/ folder) rather than the asset
/// catalog because UNNotificationAttachment takes a file URL asset
/// catalog images aren't accessible as standalone files.
///
/// PNGs are pre-rendered at 48×48 (the measured native thumbnail size)
/// so macOS doesn't downsample them during display.
private func teamLogoAttachment(for teamAbbrev: String) -> UNNotificationAttachment? {
guard let logoURL = Bundle.main.url(
forResource: teamAbbrev, withExtension: "png", subdirectory: "TeamLogos"
) else {
logger.debug("No logo found for \(teamAbbrev)")
return nil
}
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)
return try UNNotificationAttachment(
identifier: "team-logo-\(teamAbbrev)",
url: tempFile,
options: [
UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier,
UNNotificationAttachmentOptionsThumbnailHiddenKey: false
]
)
} 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()
gameEndsSent.removeAll()
}
#if DEBUG
// MARK: - Thumbnail Size Test
/// Fires one notification per size in the ladder with a distinctive test
/// image generated at that exact pixel size. The crispest thumbnail
/// reveals the notification system's native display size.
func sendThumbnailSizeTest() {
let sizes = [16, 24, 32, 40, 48, 56, 64, 80, 96, 128]
for size in sizes {
guard let attachment = testAttachment(size: size) else {
logger.warning("Failed to create test attachment for size \(size)")
continue
}
let content = UNMutableNotificationContent()
content.title = "Thumb Test"
content.body = "\(size)px"
content.sound = nil
content.interruptionLevel = .active
content.attachments = [attachment]
let request = UNNotificationRequest(
identifier: "thumb-test-\(size)-\(UUID().uuidString)",
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
)
UNUserNotificationCenter.current().add(request) { [weak self] error in
if let error = error {
self?.logger.error("Thumb test \(size)px failed: \(error.localizedDescription)")
}
}
}
}
/// Builds a UNNotificationAttachment with a generated NxN PNG.
/// Pattern: solid white background, 1-source-pixel black border, a
/// small centered 4x4 black pixel grid (so we can see pixel-level
/// behaviour). Unique filename + attachment identifier so macOS
/// treats each size as a distinct attachment (no cache reuse).
private func testAttachment(size: Int) -> UNNotificationAttachment? {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("thumb-test", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempFile = tempDir.appendingPathComponent("thumb-\(size)-\(UUID().uuidString).png")
guard generateTestPNG(size: size, at: tempFile) else { return nil }
do {
return try UNNotificationAttachment(
identifier: "thumb-test-attachment-\(size)-\(UUID().uuidString)",
url: tempFile,
options: [
UNNotificationAttachmentOptionsTypeHintKey: UTType.png.identifier,
UNNotificationAttachmentOptionsThumbnailHiddenKey: false
]
)
} catch {
logger.error("Failed to create thumb test attachment (\(size)): \(error.localizedDescription)")
return nil
}
}
private func generateTestPNG(size: Int, at url: URL) -> Bool {
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let ctx = CGContext(
data: nil, width: size, height: size,
bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return false }
// White fill
ctx.setFillColor(red: 1, green: 1, blue: 1, alpha: 1)
ctx.fill(CGRect(x: 0, y: 0, width: size, height: size))
// 1-pixel black border (no antialiasing for pixel accuracy)
ctx.setShouldAntialias(false)
ctx.setFillColor(red: 0, green: 0, blue: 0, alpha: 1)
// Top row
ctx.fill(CGRect(x: 0, y: size - 1, width: size, height: 1))
// Bottom row
ctx.fill(CGRect(x: 0, y: 0, width: size, height: 1))
// Left column
ctx.fill(CGRect(x: 0, y: 0, width: 1, height: size))
// Right column
ctx.fill(CGRect(x: size - 1, y: 0, width: 1, height: size))
// Centered 4x4 black square at exact source pixel positions
let cx = size / 2
let cy = size / 2
ctx.fill(CGRect(x: cx - 2, y: cy - 2, width: 4, height: 4))
guard let image = ctx.makeImage(),
let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)
else { return false }
CGImageDestinationAddImage(dest, image, nil)
return CGImageDestinationFinalize(dest)
}
#endif
}