Add iPhone target with shared data layer and persistent cache
Two-target restructure: shared sources (models, services, settings, extensions, team logos) move into Shared/, consumed by both the existing macOS menu bar app and a new iOS app. MainService no longer imports AppKit — platform code attaches via a MainServiceObserver protocol (MacObserverAdapter wires back to MenuManager / StatusItemManager / NotificationManager). iPhone app is a single SwiftUI page mirroring the macOS menu (playoff round + yesterday/today/tomorrow), with a gear-icon settings sheet (display option + IndieAbout for license/changelog). Persistent JSON snapshot in Application Support paints last-known data on cold launch; "Updated …" header escalates secondary → orange (>5min) → red (>30min) so staleness is visually unmistakable. Foreground polling, scenePhase refresh, and pull-to-refresh; no notifications on iOS in v1.
@@ -2,6 +2,9 @@
|
||||
|
||||
## April 2026
|
||||
|
||||
- New iPhone target (`IceGlass-iOS`): single-page SwiftUI app mirroring the macOS menu's playoff round + yesterday/today/tomorrow content, with a gear-icon settings sheet (display option + IndieAbout)
|
||||
- Persistent JSON cache (`Shared/Services/NHLDataCache.swift`) stored in Application Support so iPhone cold launches paint last-known data immediately; surfaced with a stale-aware "Updated …" header (secondary → orange after 5 min → red after 30 min)
|
||||
- Shared code refactored into a top-level `Shared/` directory consumed by both targets; introduced `MainServiceObserver` protocol so `MainService` no longer imports AppKit
|
||||
- Team logo download pipeline now produces 128×128 transparent squares directly (via a new `Scripts/square_logo.swift` helper called from `download_team_logos.sh`), so notification attachments can ship the bundled PNG as-is and the banner thumbnail renders crisply without runtime compositing
|
||||
- Game state tag (PRE / LIVE / CRIT / OVER / FINAL / OFF) now surfaced on each daily game row and on live series rows in the ROUND block
|
||||
- Playoff series rows in the ROUND block are always clickable (open the NHL series page) and mark completed series as "Final"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
MainView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// IceGlassApp.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IceGlassApp: App {
|
||||
@State private var viewModel = ScoreboardViewModel()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(viewModel)
|
||||
.onAppear { viewModel.attach() }
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
switch newPhase {
|
||||
case .active:
|
||||
viewModel.handleScenePhaseActive()
|
||||
case .background, .inactive:
|
||||
viewModel.handleScenePhaseInactive()
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-ios-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 211 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>IceGlass</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// ScoreboardViewModel.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Bridges MainService → SwiftUI. Re-published whenever data lands so views
|
||||
/// re-render. Owns refresh kick-off on scene activation and pull-to-refresh.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ScoreboardViewModel {
|
||||
/// Bumped on every observer callback; views read it implicitly via the
|
||||
/// Observation framework so the entire tree re-evaluates.
|
||||
private var revision: Int = 0
|
||||
|
||||
/// Tracks the most recent successful fetch time for the "Updated …" header.
|
||||
var lastUpdated: Date?
|
||||
|
||||
/// Toggled by pull-to-refresh; views can show a spinner.
|
||||
var isRefreshing: Bool = false
|
||||
|
||||
/// Settings — surfaced separately so the settings sheet can bind directly.
|
||||
var displayOption: AppSettings.DisplayOption {
|
||||
get {
|
||||
_ = revision
|
||||
return AppSettings.shared.displayOption
|
||||
}
|
||||
set {
|
||||
AppSettings.shared.displayOption = newValue
|
||||
revision += 1
|
||||
}
|
||||
}
|
||||
|
||||
var gamesByDate: [Scoreboard.GameDay] {
|
||||
_ = revision
|
||||
return MainService.shared.gamesByDate
|
||||
}
|
||||
|
||||
var currentRoundSeriesItems: [MainService.RoundSeriesItem] {
|
||||
_ = revision
|
||||
return MainService.shared.currentRoundSeriesItems
|
||||
}
|
||||
|
||||
var currentRoundNumber: Int? {
|
||||
_ = revision
|
||||
return MainService.shared.bracket?.currentRound
|
||||
}
|
||||
|
||||
/// Bridge object that forwards MainService callbacks back to this view model.
|
||||
/// Held strongly so MainService's weak observer reference stays alive.
|
||||
private var bridge: ObserverBridge?
|
||||
|
||||
init() {}
|
||||
|
||||
/// Wire this view model up to MainService. Idempotent; called from `.onAppear`.
|
||||
func attach() {
|
||||
guard bridge == nil else { return }
|
||||
let bridge = ObserverBridge { [weak self] in self?.handleUpdate() }
|
||||
self.bridge = bridge
|
||||
MainService.shared.observer = bridge
|
||||
// Initial sync — MainService may have already loaded a cached snapshot.
|
||||
handleUpdate()
|
||||
}
|
||||
|
||||
private func handleUpdate() {
|
||||
lastUpdated = MainService.shared.lastUpdated
|
||||
revision += 1
|
||||
}
|
||||
|
||||
func refreshNow() async {
|
||||
isRefreshing = true
|
||||
await MainService.shared.fetchAll()
|
||||
isRefreshing = false
|
||||
}
|
||||
|
||||
func handleScenePhaseActive() {
|
||||
MainService.shared.resumePolling()
|
||||
Task { await refreshNow() }
|
||||
}
|
||||
|
||||
func handleScenePhaseInactive() {
|
||||
MainService.shared.suspendPolling()
|
||||
}
|
||||
}
|
||||
|
||||
/// Class adapter so MainService's `weak var observer` has something to hold;
|
||||
/// @Observable view-model classes don't compose cleanly with weak refs.
|
||||
@MainActor
|
||||
private final class ObserverBridge: MainServiceObserver {
|
||||
private let onUpdate: () -> Void
|
||||
|
||||
init(onUpdate: @escaping () -> Void) {
|
||||
self.onUpdate = onUpdate
|
||||
}
|
||||
|
||||
func mainServiceDidUpdate() {
|
||||
onUpdate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// GameDaySection.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GameDaySection: View {
|
||||
let gameDay: Scoreboard.GameDay
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(headerText)
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if gameDay.games.isEmpty {
|
||||
Text("No games scheduled")
|
||||
.font(.subheadline)
|
||||
.italic()
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(gameDay.games.enumerated()), id: \.element.id) { idx, game in
|
||||
GameRow(game: game)
|
||||
if idx < gameDay.games.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerText: String {
|
||||
let label = Date.fullDateLabel(for: gameDay.date)
|
||||
return "\(label) (\(gameDay.games.count))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// GameRow.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GameRow: View {
|
||||
let game: Scoreboard.Game
|
||||
|
||||
var body: some View {
|
||||
Button(action: open) {
|
||||
HStack(spacing: 12) {
|
||||
if game.gameType == 2 {
|
||||
Text("#\(game.seasonGameNumber)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.tertiary)
|
||||
.frame(width: 44, alignment: .leading)
|
||||
}
|
||||
|
||||
Text("\(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
rightContent
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var rightContent: some View {
|
||||
let state = game.parsedGameState
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
if state.isFuture {
|
||||
Text(game.startTimeET.trimmingCharacters(in: .whitespaces))
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(scoreText)
|
||||
.font(.body.monospacedDigit())
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
Text(statusLine)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(state.isLive ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var scoreText: String {
|
||||
let a = game.awayTeam.score ?? 0
|
||||
let h = game.homeTeam.score ?? 0
|
||||
return "\(a) – \(h)"
|
||||
}
|
||||
|
||||
private var statusLine: String {
|
||||
let state = game.parsedGameState
|
||||
let tag = state.shortTag
|
||||
let time = game.startTimeET.trimmingCharacters(in: .whitespaces)
|
||||
if tag.isEmpty { return time }
|
||||
if state.isLive { return tag }
|
||||
return tag
|
||||
}
|
||||
|
||||
private func open() {
|
||||
guard let url = URL(string: game.gameCenterUrl) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// MainView.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@Environment(ScoreboardViewModel.self) private var viewModel
|
||||
@State private var showingSettings = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 16, pinnedViews: []) {
|
||||
UpdatedHeader(
|
||||
lastUpdated: viewModel.lastUpdated,
|
||||
isRefreshing: viewModel.isRefreshing
|
||||
)
|
||||
.padding(.horizontal)
|
||||
|
||||
if !viewModel.currentRoundSeriesItems.isEmpty,
|
||||
let round = viewModel.currentRoundNumber {
|
||||
PlayoffRoundSection(
|
||||
round: round,
|
||||
items: viewModel.currentRoundSeriesItems
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
let gameDays = viewModel.gamesByDate
|
||||
if gameDays.isEmpty && viewModel.currentRoundSeriesItems.isEmpty {
|
||||
emptyState
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
ForEach(gameDays, id: \.date) { gameDay in
|
||||
GameDaySection(gameDay: gameDay)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refreshNow()
|
||||
}
|
||||
.navigationTitle("IceGlass")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "hockey.puck")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No games scheduled")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Pull down to refresh")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// PlayoffRoundSection.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct PlayoffRoundSection: View {
|
||||
let round: Int
|
||||
let items: [MainService.RoundSeriesItem]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("ROUND \(round)")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(items, id: \.series.seriesLetter) { item in
|
||||
SeriesRow(item: item)
|
||||
if item.series.seriesLetter != items.last?.series.seriesLetter {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// SeriesRow.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SeriesRow: View {
|
||||
let item: MainService.RoundSeriesItem
|
||||
|
||||
var body: some View {
|
||||
Button(action: open) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(matchupText)
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
Text(scoreText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
if let trailing = trailingText {
|
||||
Text(trailing)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var matchupText: String {
|
||||
let top = item.series.topSeedTeam?.abbrev ?? "TBD"
|
||||
let bottom = item.series.bottomSeedTeam?.abbrev ?? "TBD"
|
||||
return "\(bottom) @ \(top)"
|
||||
}
|
||||
|
||||
private var scoreText: String {
|
||||
"\(item.series.bottomSeedWins) – \(item.series.topSeedWins)"
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
if let winner = item.series.winner {
|
||||
return "Final · \(winner) wins"
|
||||
}
|
||||
if let n = item.series.nextGameNumber {
|
||||
return "Game \(n)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private var trailingText: String? {
|
||||
guard item.series.winner == nil else { return nil }
|
||||
return item.nextGame?.nextGameLabel
|
||||
}
|
||||
|
||||
private func open() {
|
||||
guard let urlString = item.series.fullSeriesUrl,
|
||||
let url = URL(string: urlString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// SettingsSheet.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieAbout
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsSheet: View {
|
||||
@Environment(ScoreboardViewModel.self) private var viewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Display") {
|
||||
Picker("Days shown", selection: Binding(
|
||||
get: { vm.displayOption },
|
||||
set: { newValue in
|
||||
vm.displayOption = newValue
|
||||
MainService.shared.updateUI()
|
||||
}
|
||||
)) {
|
||||
ForEach(AppSettings.DisplayOption.allCases, id: \.self) { option in
|
||||
Text(option.title).tag(option)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Section {
|
||||
IndieAbout(configuration: AppInfoConfiguration(
|
||||
showDeviceInfo: false,
|
||||
documents: [
|
||||
.license(filename: "LICENSE", extension: "md"),
|
||||
.custom(title: "Changelog", filename: "CHANGELOG", extension: "md")
|
||||
]
|
||||
))
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// UpdatedHeader.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// "Updated 2 min ago" + "as of Apr 24, 4:32 PM ET" header.
|
||||
/// Color escalates as data ages: secondary → orange (>5min) → red (>30min or never).
|
||||
/// Self-refreshes every 30s via TimelineView so the relative label stays current
|
||||
/// without a network call.
|
||||
struct UpdatedHeader: View {
|
||||
let lastUpdated: Date?
|
||||
let isRefreshing: Bool
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 30)) { context in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(relativeLabel(now: context.date))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
if let absolute = absoluteLabel() {
|
||||
Text(absolute)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(staleness(now: context.date).color)
|
||||
|
||||
if isRefreshing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum Staleness {
|
||||
case fresh
|
||||
case warm // >5 min
|
||||
case stale // >30 min or no data
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .fresh: return .secondary
|
||||
case .warm: return .orange
|
||||
case .stale: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func staleness(now: Date) -> Staleness {
|
||||
guard let lastUpdated else { return .stale }
|
||||
let age = now.timeIntervalSince(lastUpdated)
|
||||
if age > 30 * 60 { return .stale }
|
||||
if age > 5 * 60 { return .warm }
|
||||
return .fresh
|
||||
}
|
||||
|
||||
private func relativeLabel(now: Date) -> String {
|
||||
guard let lastUpdated else { return "Never updated" }
|
||||
let age = now.timeIntervalSince(lastUpdated)
|
||||
if age < 60 { return "Updated just now" }
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return "Updated \(formatter.localizedString(for: lastUpdated, relativeTo: now))"
|
||||
}
|
||||
|
||||
private func absoluteLabel() -> String? {
|
||||
guard let lastUpdated else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, h:mm a"
|
||||
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
||||
let stamp = formatter.string(from: lastUpdated)
|
||||
return "as of \(stamp) ET"
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
)
|
||||
|
||||
private var mainService = MainService.shared
|
||||
private let observerAdapter = MacObserverAdapter()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
logger.info("applicationDidFinishLaunching")
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Install MainService → AppKit bridge before any data flows in.
|
||||
mainService.observer = observerAdapter
|
||||
|
||||
// Force re-register with Launch Services to refresh cached icon
|
||||
LSRegisterURL(Bundle.main.bundleURL as CFURL, true)
|
||||
|
||||
@@ -48,3 +52,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges MainService callbacks to the macOS-only managers so MainService
|
||||
/// stays AppKit-free and shareable with the iOS target.
|
||||
@MainActor
|
||||
final class MacObserverAdapter: MainServiceObserver {
|
||||
func mainServiceDidUpdate() {
|
||||
StatusItemManager.shared.updateStatusText(MainService.shared.statusBarText)
|
||||
MenuManager.shared.scoreboardChanged()
|
||||
}
|
||||
|
||||
func mainServiceDidDetectGameStart(_ game: Scoreboard.Game) {
|
||||
NotificationManager.shared.notifyGameStarted(game)
|
||||
}
|
||||
|
||||
func mainServiceDidDetectGoal(
|
||||
_ game: Scoreboard.Game,
|
||||
scoringTeam: Scoreboard.Game.Team,
|
||||
scorer: GoalScorer?
|
||||
) {
|
||||
NotificationManager.shared.notifyGoalScored(game, scoringTeam: scoringTeam, scorer: scorer)
|
||||
}
|
||||
|
||||
func mainServiceDidDetectGameEnd(_ game: Scoreboard.Game) {
|
||||
NotificationManager.shared.notifyGameEnded(game)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +366,7 @@ class MenuManager: @unchecked Sendable {
|
||||
trailing = ""
|
||||
} else if let n = series.nextGameNumber {
|
||||
statusTag = "(Game \(n))"
|
||||
trailing = roundItem.nextGame.map { Self.nextGameLabel(for: $0) } ?? ""
|
||||
trailing = roundItem.nextGame?.nextGameLabel ?? ""
|
||||
} else {
|
||||
statusTag = ""
|
||||
trailing = ""
|
||||
@@ -381,28 +381,4 @@ class MenuManager: @unchecked Sendable {
|
||||
return item
|
||||
}
|
||||
|
||||
private static func nextGameLabel(for game: Scoreboard.Game) -> String {
|
||||
let state = game.parsedGameState
|
||||
if state.isLive {
|
||||
return state.shortTag
|
||||
}
|
||||
let dayLabel: String
|
||||
switch game.gameDate {
|
||||
case Date.todayET: dayLabel = "Today"
|
||||
case Date.tomorrowET: dayLabel = "Tomorrow"
|
||||
case Date.yesterdayET: dayLabel = "Yesterday"
|
||||
default:
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
||||
if let date = formatter.date(from: game.gameDate) {
|
||||
dayLabel = date.formatDateET(format: "EEE")
|
||||
} else {
|
||||
dayLabel = ""
|
||||
}
|
||||
}
|
||||
let time = game.startTimeET.trimmingCharacters(in: .whitespaces)
|
||||
let base = "\(dayLabel) \(time)"
|
||||
return state == .pre ? "\(base) (PRE)" : base
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# IceGlass
|
||||
|
||||
A macOS menu bar app for NHL game situational awareness.
|
||||
NHL game situational awareness for macOS (menu bar) and iPhone (single-page app).
|
||||
|
||||
## Key Features
|
||||
|
||||
### macOS menu bar app
|
||||
- NHL shield icon in the menu bar with game count
|
||||
- Shows games from yesterday, today, and tomorrow grouped by date (configurable)
|
||||
- Regular-season rows show league-wide game number (`#547 NYR @ WAS …`)
|
||||
@@ -11,28 +12,37 @@ A macOS menu bar app for NHL game situational awareness.
|
||||
- Game format: `NYR @ WAS 0:2 (FINAL)` / `DAL @ TOR Today @ 7:30 PM`
|
||||
- Click a game to open NHL GameCenter; option-click for NHL Videocast
|
||||
- Goal scored notifications with scoring team logo
|
||||
- Game start notifications on FUT→LIVE state transition
|
||||
- Game start / game ended notifications
|
||||
- Dynamic polling: 7s during live games, scales back when idle
|
||||
- Display Options: choose which days to show (yesterday/today/tomorrow)
|
||||
- Refresh Now (⌘R) for immediate updates
|
||||
- Launch at Login support
|
||||
- About window via IndieAbout
|
||||
|
||||
### iPhone app
|
||||
- Single scrollable page mirroring the macOS menu's content
|
||||
- "Updated …" header showing relative + absolute ET timestamps; turns orange after 5 min, red after 30 min
|
||||
- Persistent JSON cache in Application Support so cold launches paint last-known data instantly
|
||||
- Pull-to-refresh, plus auto-refresh on scene activation and foreground polling timer
|
||||
- Settings sheet (gear, top-right): display option picker + IndieAbout (license + changelog)
|
||||
- Tap a game to open NHL GameCenter; tap a series to open the NHL series page
|
||||
|
||||
## Building
|
||||
|
||||
Requires XcodeGen to generate the project:
|
||||
|
||||
```bash
|
||||
xcodegen generate
|
||||
xcodebuild -scheme IceGlass -configuration Debug build
|
||||
xcodebuild -scheme IceGlass -configuration Debug build # macOS
|
||||
xcodebuild -scheme IceGlass-iOS -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Menu bar app using singleton services pattern:
|
||||
- **MainService** — polls NHL scoreboard API, manages game data
|
||||
- **MenuManager** — builds dropdown menu with date-grouped games
|
||||
- **StatusItemManager** — manages menu bar icon
|
||||
- **NotificationManager** — game start and goal scored notifications with team logos
|
||||
Two targets sharing a common data layer (`Shared/`) and platform-specific UI:
|
||||
|
||||
Uses the NHL Web API (`api-web.nhle.com/v1/scoreboard/now`) for league-wide schedule and score data.
|
||||
- **Shared/** — `MainService`, `ApiService`, `AppSettings`, `NHLDataCache`, models, Date/Time/Logger helpers
|
||||
- **IceGlass/** — macOS-only managers (MenuManager, StatusItemManager, NotificationManager, AboutWindow)
|
||||
- **IceGlass-iOS/** — SwiftUI views, ScoreboardViewModel (`@Observable`)
|
||||
|
||||
`MainService` exposes a `MainServiceObserver` protocol; each platform installs its own adapter to bridge data updates into AppKit (macOS) or `@Observable` invalidation (iOS). Data is fetched from the NHL Web API (`api-web.nhle.com/v1/scoreboard/now`, `/standings/{date}`, `/playoff-bracket/{year}`).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// Game+nextGameLabel.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Scoreboard.Game {
|
||||
/// Friendly "next game" label used by both the macOS menu and the iOS
|
||||
/// playoff series row: "Today 9:30 PM", "Tomorrow 7:00 PM", "Wed 7:00 PM (PRE)",
|
||||
/// or just "LIVE" when the game is in progress.
|
||||
var nextGameLabel: String {
|
||||
let state = parsedGameState
|
||||
if state.isLive {
|
||||
return state.shortTag
|
||||
}
|
||||
let dayLabel: String
|
||||
switch gameDate {
|
||||
case Date.todayET: dayLabel = "Today"
|
||||
case Date.tomorrowET: dayLabel = "Tomorrow"
|
||||
case Date.yesterdayET: dayLabel = "Yesterday"
|
||||
default:
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
||||
if let date = formatter.date(from: gameDate) {
|
||||
dayLabel = date.formatDateET(format: "EEE")
|
||||
} else {
|
||||
dayLabel = ""
|
||||
}
|
||||
}
|
||||
let time = startTimeET.trimmingCharacters(in: .whitespaces)
|
||||
let base = "\(dayLabel) \(time)"
|
||||
return state == .pre ? "\(base) (PRE)" : base
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -22,10 +22,7 @@ class ApiService: @unchecked Sendable {
|
||||
|
||||
init(url: URL?, callback: @escaping ApiServiceCallback) {
|
||||
guard let url = url else {
|
||||
AppTerminator.terminate()
|
||||
self.url = URL(string: "")!
|
||||
self.callback = { (_: Data, _: URLResponse) in }
|
||||
return
|
||||
preconditionFailure("ApiService initialised with nil URL — caller bug.")
|
||||
}
|
||||
self.url = url
|
||||
self.callback = callback
|
||||
@@ -7,6 +7,36 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Platform-agnostic callback so MainService doesn't import AppKit/UIKit.
|
||||
/// macOS sets this to drive MenuManager + StatusItemManager + NotificationManager;
|
||||
/// iOS sets it to invalidate its @Observable view model.
|
||||
@MainActor
|
||||
protocol MainServiceObserver: AnyObject {
|
||||
/// Fired after every successful scoreboard / standings / bracket update.
|
||||
func mainServiceDidUpdate()
|
||||
|
||||
/// Fired when a future game transitions to live. macOS shows a notification;
|
||||
/// iOS v1 ignores (no notifications).
|
||||
func mainServiceDidDetectGameStart(_ game: Scoreboard.Game)
|
||||
|
||||
/// Fired when a goal is detected. `scorer` may be nil if play-by-play hasn't
|
||||
/// caught up yet.
|
||||
func mainServiceDidDetectGoal(
|
||||
_ game: Scoreboard.Game,
|
||||
scoringTeam: Scoreboard.Game.Team,
|
||||
scorer: GoalScorer?
|
||||
)
|
||||
|
||||
/// Fired when a game transitions to a final state (OVER/FINAL/OFF).
|
||||
func mainServiceDidDetectGameEnd(_ game: Scoreboard.Game)
|
||||
}
|
||||
|
||||
extension MainServiceObserver {
|
||||
func mainServiceDidDetectGameStart(_: Scoreboard.Game) {}
|
||||
func mainServiceDidDetectGoal(_: Scoreboard.Game, scoringTeam: Scoreboard.Game.Team, scorer: GoalScorer?) {}
|
||||
func mainServiceDidDetectGameEnd(_: Scoreboard.Game) {}
|
||||
}
|
||||
|
||||
class MainService: @unchecked Sendable {
|
||||
private let logger = IceGlassLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
||||
@@ -15,10 +45,14 @@ class MainService: @unchecked Sendable {
|
||||
|
||||
static let shared = MainService()
|
||||
|
||||
private lazy var menuManager = MenuManager.shared
|
||||
private lazy var statusItemManager = StatusItemManager.shared
|
||||
private lazy var settings = AppSettings.shared
|
||||
private lazy var notificationManager = NotificationManager.shared
|
||||
private let cache = NHLDataCache()
|
||||
|
||||
/// Set by each platform's app entry to receive update callbacks.
|
||||
/// Always invoked on the main actor by the methods below; the property
|
||||
/// itself is unisolated so MainService.shared can be referenced from
|
||||
/// nonisolated contexts (e.g. AppDelegate's stored property init).
|
||||
nonisolated(unsafe) weak var observer: (any MainServiceObserver)?
|
||||
|
||||
private var pollingTimer: Timer?
|
||||
private var scoreboardApi: ApiService?
|
||||
@@ -35,10 +69,14 @@ class MainService: @unchecked Sendable {
|
||||
/// Current playoff bracket (nil during regular season or before first fetch)
|
||||
var bracket: PlayoffBracket?
|
||||
|
||||
/// Timestamp of the most recent successful fetch (any endpoint).
|
||||
/// Surface this in the iOS UI as the "as of" indicator.
|
||||
private(set) var lastUpdated: Date?
|
||||
|
||||
/// Previous game snapshots for detecting state/score changes (keyed by game ID)
|
||||
private var previousGameStates: [Int: GameSnapshot] = [:]
|
||||
|
||||
/// Whether this is the first fe tch (suppress notifications on initial load)
|
||||
/// Whether this is the first fetch (suppress notifications on initial load)
|
||||
private var isFirstFetch = true
|
||||
|
||||
/// Set during change detection when a playoff game transitions to a final state,
|
||||
@@ -118,12 +156,10 @@ class MainService: @unchecked Sendable {
|
||||
private init() {
|
||||
logger.debug("Initializing")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
AppTerminator.terminate()
|
||||
return
|
||||
}
|
||||
guard let self = self else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
await self.loadFromCache()
|
||||
self.initApis()
|
||||
self.reschedulePollingTimer(.bootstrap)
|
||||
await self.fetchScoreboard()
|
||||
@@ -132,6 +168,48 @@ class MainService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
/// Loads the persisted snapshot (if any) so the UI can paint last-known
|
||||
/// data immediately. The fresh fetch in `init` then overwrites in-memory
|
||||
/// state and the cache file.
|
||||
private func loadFromCache() async {
|
||||
guard let snapshot = await cache.load() else { return }
|
||||
if let scoreboard = snapshot.scoreboard {
|
||||
let yesterday = Date.yesterdayET
|
||||
let today = Date.todayET
|
||||
let tomorrow = Date.tomorrowET
|
||||
let windowDates = Set([yesterday, today, tomorrow])
|
||||
let filtered = scoreboard.gamesByDate.filter { windowDates.contains($0.date) }
|
||||
self.allGamesByDate = filtered
|
||||
self.updateSnapshots(from: filtered)
|
||||
}
|
||||
self.standings = snapshot.standings
|
||||
self.bracket = snapshot.bracket
|
||||
self.lastUpdated = snapshot.lastUpdated
|
||||
logger.info("Loaded cached snapshot from \(snapshot.lastUpdated)")
|
||||
Task { @MainActor in self.observer?.mainServiceDidUpdate() }
|
||||
}
|
||||
|
||||
private func persistCache() {
|
||||
// Reconstruct a synthetic Scoreboard from the windowed allGamesByDate
|
||||
// so the cached payload is exactly what we'd render on next launch.
|
||||
let scoreboard = Scoreboard(
|
||||
focusedDate: Date.todayET,
|
||||
focusedDateCount: allGamesByDate.first { $0.date == Date.todayET }?.games.count ?? 0,
|
||||
gamesByDate: allGamesByDate
|
||||
)
|
||||
let snapshot = CachedSnapshot(
|
||||
lastUpdated: Date(),
|
||||
scoreboard: scoreboard,
|
||||
standings: standings,
|
||||
bracket: bracket
|
||||
)
|
||||
Task { [cache] in
|
||||
await cache.save(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func initApis() {
|
||||
scoreboardApi = ApiService(
|
||||
url: URL(string: "https://api-web.nhle.com/v1/scoreboard/now")
|
||||
@@ -154,20 +232,12 @@ class MainService: @unchecked Sendable {
|
||||
|
||||
self.allGamesByDate = filtered
|
||||
self.updateSnapshots(from: filtered)
|
||||
self.lastUpdated = Date()
|
||||
|
||||
let wasFirstFetch = self.isFirstFetch
|
||||
if self.isFirstFetch {
|
||||
self.isFirstFetch = false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Fire a test game-start notification on startup so the dev
|
||||
// loop doesn't require clicking through the menu each time.
|
||||
if wasFirstFetch, let game = filtered.flatMap(\.games).first {
|
||||
self.notificationManager.notifyGameStarted(game, bypassDedup: true)
|
||||
}
|
||||
#endif
|
||||
|
||||
self.logger.info("Scoreboard updated: \(filtered.map { "\($0.date): \($0.games.count) games" }.joined(separator: ", "))")
|
||||
|
||||
let interval = self.bestPollingInterval
|
||||
@@ -178,7 +248,8 @@ class MainService: @unchecked Sendable {
|
||||
await self.refreshBracketIfNeeded(from: filtered, force: shouldForceBracket)
|
||||
}
|
||||
|
||||
self.updateUI()
|
||||
self.persistCache()
|
||||
self.notifyObserverDidUpdate()
|
||||
} catch {
|
||||
self.logger.error("Failed to decode scoreboard: \(error.localizedDescription)")
|
||||
}
|
||||
@@ -191,17 +262,26 @@ class MainService: @unchecked Sendable {
|
||||
|
||||
do {
|
||||
self.standings = try JSONDecoder().decode(Standings.self, from: data)
|
||||
self.lastUpdated = Date()
|
||||
self.logger.info("Standings updated: \(self.standings?.standings.count ?? 0) teams")
|
||||
self.updateUI()
|
||||
self.persistCache()
|
||||
self.notifyObserverDidUpdate()
|
||||
} catch {
|
||||
self.logger.error("Failed to decode standings: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyObserverDidUpdate() {
|
||||
Task { @MainActor in
|
||||
self.observer?.mainServiceDidUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires `mainServiceDidUpdate` without triggering a network fetch — call
|
||||
/// after settings changes that affect what the existing data renders as.
|
||||
func updateUI() {
|
||||
statusItemManager.updateStatusText(statusBarText)
|
||||
menuManager.scoreboardChanged()
|
||||
notifyObserverDidUpdate()
|
||||
}
|
||||
|
||||
// MARK: - Change Detection
|
||||
@@ -216,13 +296,15 @@ class MainService: @unchecked Sendable {
|
||||
|
||||
if let prevState = previousState, prevState.isFuture, currentState.isLive {
|
||||
logger.info("Game \(game.id) started: \(game.awayTeam.abbrev) @ \(game.homeTeam.abbrev)")
|
||||
notificationManager.notifyGameStarted(game)
|
||||
let captured = game
|
||||
Task { @MainActor in self.observer?.mainServiceDidDetectGameStart(captured) }
|
||||
}
|
||||
|
||||
// Game ended: transition to any over-state (OVER/FINAL/OFF)
|
||||
if let prevState = previousState, !prevState.isOver, currentState.isOver {
|
||||
logger.info("Game \(game.id) ended: \(game.awayTeam.abbrev) \(game.awayTeam.score ?? 0) — \(game.homeTeam.abbrev) \(game.homeTeam.score ?? 0)")
|
||||
notificationManager.notifyGameEnded(game)
|
||||
let captured = game
|
||||
Task { @MainActor in self.observer?.mainServiceDidDetectGameEnd(captured) }
|
||||
}
|
||||
|
||||
if game.gameType == 3,
|
||||
@@ -253,7 +335,9 @@ class MainService: @unchecked Sendable {
|
||||
awayScore: awayScore,
|
||||
homeScore: homeScore
|
||||
)
|
||||
self.notificationManager.notifyGoalScored(game, scoringTeam: scoringTeam, scorer: scorer)
|
||||
await MainActor.run {
|
||||
self.observer?.mainServiceDidDetectGoal(game, scoringTeam: scoringTeam, scorer: scorer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +412,20 @@ class MainService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the polling timer (call from iOS when entering background).
|
||||
@MainActor
|
||||
func suspendPolling() {
|
||||
pollingTimer?.invalidate()
|
||||
pollingTimer = nil
|
||||
logger.debug("Polling suspended")
|
||||
}
|
||||
|
||||
/// Resume polling at the appropriate interval (call from iOS on scenePhase=.active).
|
||||
@MainActor
|
||||
func resumePolling() {
|
||||
reschedulePollingTimer(bestPollingInterval)
|
||||
}
|
||||
|
||||
private func fetchScoreboard() async {
|
||||
await scoreboardApi?.fetch()
|
||||
}
|
||||
@@ -360,8 +458,10 @@ class MainService: @unchecked Sendable {
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
self.bracket = try JSONDecoder().decode(PlayoffBracket.self, from: data)
|
||||
self.lastUpdated = Date()
|
||||
self.logger.info("Bracket updated: \(self.bracket?.series.count ?? 0) series (round \(self.bracket?.currentRound ?? 0))")
|
||||
self.updateUI()
|
||||
self.persistCache()
|
||||
self.notifyObserverDidUpdate()
|
||||
} catch {
|
||||
self.logger.error("Failed to decode bracket: \(error.localizedDescription)")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// NHLDataCache.swift
|
||||
// IceGlass
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Snapshot of API state persisted between app launches so cold-launch shows
|
||||
/// last-known data with an "as-of" timestamp instead of an empty page.
|
||||
struct CachedSnapshot: Codable, Sendable {
|
||||
let lastUpdated: Date
|
||||
let scoreboard: Scoreboard?
|
||||
let standings: Standings?
|
||||
let bracket: PlayoffBracket?
|
||||
}
|
||||
|
||||
/// Single-file Codable cache in Application Support. Atomic writes; fail-soft
|
||||
/// reads — corrupt or version-mismatched payloads return nil and the next
|
||||
/// fetch overwrites with fresh data.
|
||||
actor NHLDataCache {
|
||||
private let logger = IceGlassLogger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass",
|
||||
category: "NHLDataCache"
|
||||
)
|
||||
|
||||
private let fileURL: URL
|
||||
|
||||
init(filename: String = "nhl-snapshot.json") {
|
||||
let fm = FileManager.default
|
||||
let supportDir = (try? fm.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)) ?? fm.temporaryDirectory
|
||||
|
||||
let bundleId = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.IceGlass"
|
||||
let appDir = supportDir.appendingPathComponent(bundleId, isDirectory: true)
|
||||
try? fm.createDirectory(at: appDir, withIntermediateDirectories: true)
|
||||
self.fileURL = appDir.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
func load() -> CachedSnapshot? {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
|
||||
do {
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return try decoder.decode(CachedSnapshot.self, from: data)
|
||||
} catch {
|
||||
logger.warning("Cache load failed (will refetch): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ snapshot: CachedSnapshot) {
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(snapshot)
|
||||
try data.write(to: fileURL, options: .atomic)
|
||||
} catch {
|
||||
logger.error("Cache save failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if os(macOS)
|
||||
import ServiceManagement
|
||||
#endif
|
||||
|
||||
class AppSettings: @unchecked Sendable {
|
||||
private let logger = IceGlassLogger(
|
||||
@@ -93,6 +96,7 @@ class AppSettings: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func updateLoginItem(enabled: Bool) {
|
||||
#if os(macOS)
|
||||
do {
|
||||
if enabled {
|
||||
try SMAppService.mainApp.register()
|
||||
@@ -102,6 +106,7 @@ class AppSettings: @unchecked Sendable {
|
||||
} catch {
|
||||
logger.error("Failed to \(enabled ? "enable" : "disable") launch at login: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private init() {}
|
||||
@@ -3,6 +3,7 @@ options:
|
||||
bundleIdPrefix: dev.rzen.indie
|
||||
deploymentTarget:
|
||||
macOS: "13.0"
|
||||
iOS: "17.0"
|
||||
xcodeVersion: "16.0"
|
||||
generateEmptyDirectories: true
|
||||
|
||||
@@ -16,12 +17,13 @@ targets:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- path: IceGlass
|
||||
- path: Shared
|
||||
excludes:
|
||||
- Resources/TeamLogos
|
||||
- path: IceGlass/Resources/TeamLogos
|
||||
- path: Shared/Resources/TeamLogos
|
||||
type: folder
|
||||
buildPhase: resources
|
||||
- path: IceGlass
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.IceGlass
|
||||
@@ -57,6 +59,45 @@ targets:
|
||||
com.apple.security.app-sandbox: true
|
||||
com.apple.security.network.client: true
|
||||
|
||||
IceGlass-iOS:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Shared
|
||||
excludes:
|
||||
- Resources/TeamLogos
|
||||
- path: Shared/Resources/TeamLogos
|
||||
type: folder
|
||||
buildPhase: resources
|
||||
- path: IceGlass-iOS
|
||||
excludes:
|
||||
- Resources
|
||||
- path: LICENSE.md
|
||||
buildPhase: resources
|
||||
- path: CHANGELOG.md
|
||||
buildPhase: resources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.IceGlass.iOS
|
||||
MARKETING_VERSION: "1.0.0"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
INFOPLIST_FILE: IceGlass-iOS/Info.plist
|
||||
DEVELOPMENT_TEAM: C32Z8JNLG6
|
||||
SWIFT_VERSION: "6.0"
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
PRODUCT_NAME: IceGlass
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: false
|
||||
GENERATE_INFOPLIST_FILE: false
|
||||
configs:
|
||||
Debug:
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
|
||||
Release:
|
||||
SWIFT_OPTIMIZATION_LEVEL: -O
|
||||
dependencies:
|
||||
- package: IndieAbout
|
||||
|
||||
schemes:
|
||||
IceGlass:
|
||||
build:
|
||||
@@ -66,3 +107,12 @@ schemes:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
|
||||
IceGlass-iOS:
|
||||
build:
|
||||
targets:
|
||||
IceGlass-iOS: all
|
||||
run:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
|
||||