Files
iceglass/IceGlass/Managers/NotificationManager.swift
T
rzen 541aa3d52c Show full playoff bracket, mark series results, harden API decoding
Playoffs:
- List every round played so far (Round 1 → current) instead of only the
  current round, on both macOS menu and iPhone
- Strike through the eliminated team's tricode in a finished series and drop
  the now-redundant "(Final … wins)" tag on completed earlier rounds
- Refetch the bracket when a finished game implies more completed games than
  the cached bracket records, so the series score and round no longer get
  stuck on stale data after cold launch or the NHL bracket endpoint's lag

API robustness:
- Tolerate optional gameCenterLink/startTimeUTC on TBD playoff matchups so the
  scoreboard decode no longer aborts
- Reject API state regressions via a monotonic FUT→…→OFF progression rank so a
  brief glitch can't downgrade a finished game back to "-:-"
2026-05-30 08:21:28 -04:00

356 lines
14 KiB
Swift
Raw 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
if let url = game.gameCenterUrl {
content.userInfo = ["url": url]
}
// 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
if let url = game.gameCenterUrl {
content.userInfo = ["url": url]
}
// 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
if let url = game.gameCenterUrl {
content.userInfo = ["url": url]
}
// 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
}