diff --git a/CHANGELOG.md b/CHANGELOG.md index f6ef6b4..c65d095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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" diff --git a/IceGlass-iOS/App/ContentView.swift b/IceGlass-iOS/App/ContentView.swift new file mode 100644 index 0000000..84b1613 --- /dev/null +++ b/IceGlass-iOS/App/ContentView.swift @@ -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() + } +} diff --git a/IceGlass-iOS/App/IceGlassApp.swift b/IceGlass-iOS/App/IceGlassApp.swift new file mode 100644 index 0000000..43776a2 --- /dev/null +++ b/IceGlass-iOS/App/IceGlassApp.swift @@ -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 + } + } + } + } +} diff --git a/IceGlass-iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/IceGlass-iOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/IceGlass-iOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IceGlass-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/IceGlass-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..e0830a0 --- /dev/null +++ b/IceGlass-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon-ios-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IceGlass-iOS/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png b/IceGlass-iOS/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png new file mode 100644 index 0000000..3f6fa3d Binary files /dev/null and b/IceGlass-iOS/Assets.xcassets/AppIcon.appiconset/icon-ios-1024.png differ diff --git a/IceGlass-iOS/Assets.xcassets/Contents.json b/IceGlass-iOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/IceGlass-iOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IceGlass-iOS/Info.plist b/IceGlass-iOS/Info.plist new file mode 100644 index 0000000..f8872a1 --- /dev/null +++ b/IceGlass-iOS/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleDisplayName + IceGlass + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIRequiresFullScreen + + + diff --git a/IceGlass-iOS/ViewModel/ScoreboardViewModel.swift b/IceGlass-iOS/ViewModel/ScoreboardViewModel.swift new file mode 100644 index 0000000..9f8acfe --- /dev/null +++ b/IceGlass-iOS/ViewModel/ScoreboardViewModel.swift @@ -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() + } +} diff --git a/IceGlass-iOS/Views/GameDaySection.swift b/IceGlass-iOS/Views/GameDaySection.swift new file mode 100644 index 0000000..b81ade9 --- /dev/null +++ b/IceGlass-iOS/Views/GameDaySection.swift @@ -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))" + } +} diff --git a/IceGlass-iOS/Views/GameRow.swift b/IceGlass-iOS/Views/GameRow.swift new file mode 100644 index 0000000..1b9f039 --- /dev/null +++ b/IceGlass-iOS/Views/GameRow.swift @@ -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) + } +} diff --git a/IceGlass-iOS/Views/MainView.swift b/IceGlass-iOS/Views/MainView.swift new file mode 100644 index 0000000..ffbbf70 --- /dev/null +++ b/IceGlass-iOS/Views/MainView.swift @@ -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) + } +} diff --git a/IceGlass-iOS/Views/PlayoffRoundSection.swift b/IceGlass-iOS/Views/PlayoffRoundSection.swift new file mode 100644 index 0000000..c6dde5e --- /dev/null +++ b/IceGlass-iOS/Views/PlayoffRoundSection.swift @@ -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)) + } + } +} diff --git a/IceGlass-iOS/Views/SeriesRow.swift b/IceGlass-iOS/Views/SeriesRow.swift new file mode 100644 index 0000000..4d187df --- /dev/null +++ b/IceGlass-iOS/Views/SeriesRow.swift @@ -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) + } +} diff --git a/IceGlass-iOS/Views/SettingsSheet.swift b/IceGlass-iOS/Views/SettingsSheet.swift new file mode 100644 index 0000000..8487f68 --- /dev/null +++ b/IceGlass-iOS/Views/SettingsSheet.swift @@ -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() } + } + } + } + } +} diff --git a/IceGlass-iOS/Views/UpdatedHeader.swift b/IceGlass-iOS/Views/UpdatedHeader.swift new file mode 100644 index 0000000..38d807e --- /dev/null +++ b/IceGlass-iOS/Views/UpdatedHeader.swift @@ -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" + } +} diff --git a/IceGlass/AppDelegate.swift b/IceGlass/AppDelegate.swift index a55fffc..c66d7b7 100644 --- a/IceGlass/AppDelegate.swift +++ b/IceGlass/AppDelegate.swift @@ -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) + } +} diff --git a/IceGlass/Managers/MenuManager.swift b/IceGlass/Managers/MenuManager.swift index d8209ff..d642056 100644 --- a/IceGlass/Managers/MenuManager.swift +++ b/IceGlass/Managers/MenuManager.swift @@ -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 - } } diff --git a/README.md b/README.md index ea5f791..2a41d6e 100644 --- a/README.md +++ b/README.md @@ -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}`). diff --git a/IceGlass/Extensions/Date+easternTimeZone.swift b/Shared/Extensions/Date+easternTimeZone.swift similarity index 100% rename from IceGlass/Extensions/Date+easternTimeZone.swift rename to Shared/Extensions/Date+easternTimeZone.swift diff --git a/IceGlass/Extensions/Date+etCalendar.swift b/Shared/Extensions/Date+etCalendar.swift similarity index 100% rename from IceGlass/Extensions/Date+etCalendar.swift rename to Shared/Extensions/Date+etCalendar.swift diff --git a/IceGlass/Extensions/Date+formatDateET.swift b/Shared/Extensions/Date+formatDateET.swift similarity index 100% rename from IceGlass/Extensions/Date+formatDateET.swift rename to Shared/Extensions/Date+formatDateET.swift diff --git a/IceGlass/Extensions/Date+gameWindow.swift b/Shared/Extensions/Date+gameWindow.swift similarity index 100% rename from IceGlass/Extensions/Date+gameWindow.swift rename to Shared/Extensions/Date+gameWindow.swift diff --git a/Shared/Extensions/Game+nextGameLabel.swift b/Shared/Extensions/Game+nextGameLabel.swift new file mode 100644 index 0000000..f4fc9ff --- /dev/null +++ b/Shared/Extensions/Game+nextGameLabel.swift @@ -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 + } +} diff --git a/IceGlass/Extensions/TimeInterval+humanReadableTime.swift b/Shared/Extensions/TimeInterval+humanReadableTime.swift similarity index 100% rename from IceGlass/Extensions/TimeInterval+humanReadableTime.swift rename to Shared/Extensions/TimeInterval+humanReadableTime.swift diff --git a/IceGlass/Extensions/Timer+startTimer.swift b/Shared/Extensions/Timer+startTimer.swift similarity index 100% rename from IceGlass/Extensions/Timer+startTimer.swift rename to Shared/Extensions/Timer+startTimer.swift diff --git a/IceGlass/Lib/IceGlassLogger.swift b/Shared/Lib/IceGlassLogger.swift similarity index 100% rename from IceGlass/Lib/IceGlassLogger.swift rename to Shared/Lib/IceGlassLogger.swift diff --git a/IceGlass/Models/BracketModel.swift b/Shared/Models/BracketModel.swift similarity index 100% rename from IceGlass/Models/BracketModel.swift rename to Shared/Models/BracketModel.swift diff --git a/IceGlass/Models/GameState.swift b/Shared/Models/GameState.swift similarity index 100% rename from IceGlass/Models/GameState.swift rename to Shared/Models/GameState.swift diff --git a/IceGlass/Models/PlayByPlayModel.swift b/Shared/Models/PlayByPlayModel.swift similarity index 100% rename from IceGlass/Models/PlayByPlayModel.swift rename to Shared/Models/PlayByPlayModel.swift diff --git a/IceGlass/Models/ScoreboardModel.swift b/Shared/Models/ScoreboardModel.swift similarity index 100% rename from IceGlass/Models/ScoreboardModel.swift rename to Shared/Models/ScoreboardModel.swift diff --git a/IceGlass/Models/StandingsModel.swift b/Shared/Models/StandingsModel.swift similarity index 100% rename from IceGlass/Models/StandingsModel.swift rename to Shared/Models/StandingsModel.swift diff --git a/IceGlass/Resources/TeamLogos/ANA.png b/Shared/Resources/TeamLogos/ANA.png similarity index 100% rename from IceGlass/Resources/TeamLogos/ANA.png rename to Shared/Resources/TeamLogos/ANA.png diff --git a/IceGlass/Resources/TeamLogos/BOS.png b/Shared/Resources/TeamLogos/BOS.png similarity index 100% rename from IceGlass/Resources/TeamLogos/BOS.png rename to Shared/Resources/TeamLogos/BOS.png diff --git a/IceGlass/Resources/TeamLogos/BUF.png b/Shared/Resources/TeamLogos/BUF.png similarity index 100% rename from IceGlass/Resources/TeamLogos/BUF.png rename to Shared/Resources/TeamLogos/BUF.png diff --git a/IceGlass/Resources/TeamLogos/CAR.png b/Shared/Resources/TeamLogos/CAR.png similarity index 100% rename from IceGlass/Resources/TeamLogos/CAR.png rename to Shared/Resources/TeamLogos/CAR.png diff --git a/IceGlass/Resources/TeamLogos/CBJ.png b/Shared/Resources/TeamLogos/CBJ.png similarity index 100% rename from IceGlass/Resources/TeamLogos/CBJ.png rename to Shared/Resources/TeamLogos/CBJ.png diff --git a/IceGlass/Resources/TeamLogos/CGY.png b/Shared/Resources/TeamLogos/CGY.png similarity index 100% rename from IceGlass/Resources/TeamLogos/CGY.png rename to Shared/Resources/TeamLogos/CGY.png diff --git a/IceGlass/Resources/TeamLogos/CHI.png b/Shared/Resources/TeamLogos/CHI.png similarity index 100% rename from IceGlass/Resources/TeamLogos/CHI.png rename to Shared/Resources/TeamLogos/CHI.png diff --git a/IceGlass/Resources/TeamLogos/COL.png b/Shared/Resources/TeamLogos/COL.png similarity index 100% rename from IceGlass/Resources/TeamLogos/COL.png rename to Shared/Resources/TeamLogos/COL.png diff --git a/IceGlass/Resources/TeamLogos/DAL.png b/Shared/Resources/TeamLogos/DAL.png similarity index 100% rename from IceGlass/Resources/TeamLogos/DAL.png rename to Shared/Resources/TeamLogos/DAL.png diff --git a/IceGlass/Resources/TeamLogos/DET.png b/Shared/Resources/TeamLogos/DET.png similarity index 100% rename from IceGlass/Resources/TeamLogos/DET.png rename to Shared/Resources/TeamLogos/DET.png diff --git a/IceGlass/Resources/TeamLogos/EDM.png b/Shared/Resources/TeamLogos/EDM.png similarity index 100% rename from IceGlass/Resources/TeamLogos/EDM.png rename to Shared/Resources/TeamLogos/EDM.png diff --git a/IceGlass/Resources/TeamLogos/FLA.png b/Shared/Resources/TeamLogos/FLA.png similarity index 100% rename from IceGlass/Resources/TeamLogos/FLA.png rename to Shared/Resources/TeamLogos/FLA.png diff --git a/IceGlass/Resources/TeamLogos/LAK.png b/Shared/Resources/TeamLogos/LAK.png similarity index 100% rename from IceGlass/Resources/TeamLogos/LAK.png rename to Shared/Resources/TeamLogos/LAK.png diff --git a/IceGlass/Resources/TeamLogos/MIN.png b/Shared/Resources/TeamLogos/MIN.png similarity index 100% rename from IceGlass/Resources/TeamLogos/MIN.png rename to Shared/Resources/TeamLogos/MIN.png diff --git a/IceGlass/Resources/TeamLogos/MTL.png b/Shared/Resources/TeamLogos/MTL.png similarity index 100% rename from IceGlass/Resources/TeamLogos/MTL.png rename to Shared/Resources/TeamLogos/MTL.png diff --git a/IceGlass/Resources/TeamLogos/NJD.png b/Shared/Resources/TeamLogos/NJD.png similarity index 100% rename from IceGlass/Resources/TeamLogos/NJD.png rename to Shared/Resources/TeamLogos/NJD.png diff --git a/IceGlass/Resources/TeamLogos/NSH.png b/Shared/Resources/TeamLogos/NSH.png similarity index 100% rename from IceGlass/Resources/TeamLogos/NSH.png rename to Shared/Resources/TeamLogos/NSH.png diff --git a/IceGlass/Resources/TeamLogos/NYI.png b/Shared/Resources/TeamLogos/NYI.png similarity index 100% rename from IceGlass/Resources/TeamLogos/NYI.png rename to Shared/Resources/TeamLogos/NYI.png diff --git a/IceGlass/Resources/TeamLogos/NYR.png b/Shared/Resources/TeamLogos/NYR.png similarity index 100% rename from IceGlass/Resources/TeamLogos/NYR.png rename to Shared/Resources/TeamLogos/NYR.png diff --git a/IceGlass/Resources/TeamLogos/OTT.png b/Shared/Resources/TeamLogos/OTT.png similarity index 100% rename from IceGlass/Resources/TeamLogos/OTT.png rename to Shared/Resources/TeamLogos/OTT.png diff --git a/IceGlass/Resources/TeamLogos/PHI.png b/Shared/Resources/TeamLogos/PHI.png similarity index 100% rename from IceGlass/Resources/TeamLogos/PHI.png rename to Shared/Resources/TeamLogos/PHI.png diff --git a/IceGlass/Resources/TeamLogos/PIT.png b/Shared/Resources/TeamLogos/PIT.png similarity index 100% rename from IceGlass/Resources/TeamLogos/PIT.png rename to Shared/Resources/TeamLogos/PIT.png diff --git a/IceGlass/Resources/TeamLogos/SEA.png b/Shared/Resources/TeamLogos/SEA.png similarity index 100% rename from IceGlass/Resources/TeamLogos/SEA.png rename to Shared/Resources/TeamLogos/SEA.png diff --git a/IceGlass/Resources/TeamLogos/SJS.png b/Shared/Resources/TeamLogos/SJS.png similarity index 100% rename from IceGlass/Resources/TeamLogos/SJS.png rename to Shared/Resources/TeamLogos/SJS.png diff --git a/IceGlass/Resources/TeamLogos/STL.png b/Shared/Resources/TeamLogos/STL.png similarity index 100% rename from IceGlass/Resources/TeamLogos/STL.png rename to Shared/Resources/TeamLogos/STL.png diff --git a/IceGlass/Resources/TeamLogos/TBL.png b/Shared/Resources/TeamLogos/TBL.png similarity index 100% rename from IceGlass/Resources/TeamLogos/TBL.png rename to Shared/Resources/TeamLogos/TBL.png diff --git a/IceGlass/Resources/TeamLogos/TOR.png b/Shared/Resources/TeamLogos/TOR.png similarity index 100% rename from IceGlass/Resources/TeamLogos/TOR.png rename to Shared/Resources/TeamLogos/TOR.png diff --git a/IceGlass/Resources/TeamLogos/UTA.png b/Shared/Resources/TeamLogos/UTA.png similarity index 100% rename from IceGlass/Resources/TeamLogos/UTA.png rename to Shared/Resources/TeamLogos/UTA.png diff --git a/IceGlass/Resources/TeamLogos/VAN.png b/Shared/Resources/TeamLogos/VAN.png similarity index 100% rename from IceGlass/Resources/TeamLogos/VAN.png rename to Shared/Resources/TeamLogos/VAN.png diff --git a/IceGlass/Resources/TeamLogos/VGK.png b/Shared/Resources/TeamLogos/VGK.png similarity index 100% rename from IceGlass/Resources/TeamLogos/VGK.png rename to Shared/Resources/TeamLogos/VGK.png diff --git a/IceGlass/Resources/TeamLogos/WPG.png b/Shared/Resources/TeamLogos/WPG.png similarity index 100% rename from IceGlass/Resources/TeamLogos/WPG.png rename to Shared/Resources/TeamLogos/WPG.png diff --git a/IceGlass/Resources/TeamLogos/WSH.png b/Shared/Resources/TeamLogos/WSH.png similarity index 100% rename from IceGlass/Resources/TeamLogos/WSH.png rename to Shared/Resources/TeamLogos/WSH.png diff --git a/IceGlass/Services/ApiService.swift b/Shared/Services/ApiService.swift similarity index 87% rename from IceGlass/Services/ApiService.swift rename to Shared/Services/ApiService.swift index 306b92a..36a46cd 100644 --- a/IceGlass/Services/ApiService.swift +++ b/Shared/Services/ApiService.swift @@ -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 diff --git a/IceGlass/Services/MainService.swift b/Shared/Services/MainService.swift similarity index 71% rename from IceGlass/Services/MainService.swift rename to Shared/Services/MainService.swift index 9676166..a6fa211 100644 --- a/IceGlass/Services/MainService.swift +++ b/Shared/Services/MainService.swift @@ -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)") } diff --git a/Shared/Services/NHLDataCache.swift b/Shared/Services/NHLDataCache.swift new file mode 100644 index 0000000..16118a4 --- /dev/null +++ b/Shared/Services/NHLDataCache.swift @@ -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)") + } + } +} diff --git a/IceGlass/Services/PollingInterval.swift b/Shared/Services/PollingInterval.swift similarity index 100% rename from IceGlass/Services/PollingInterval.swift rename to Shared/Services/PollingInterval.swift diff --git a/IceGlass/Managers/AppSettings.swift b/Shared/Settings/AppSettings.swift similarity index 98% rename from IceGlass/Managers/AppSettings.swift rename to Shared/Settings/AppSettings.swift index 277f2e5..c60c360 100644 --- a/IceGlass/Managers/AppSettings.swift +++ b/Shared/Settings/AppSettings.swift @@ -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() {} diff --git a/project.yml b/project.yml index 19ee3be..53174f9 100644 --- a/project.yml +++ b/project.yml @@ -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