541aa3d52c
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 "-:-"
356 lines
14 KiB
Swift
356 lines
14 KiB
Swift
//
|
||
// 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
|
||
}
|