541aa3d52c
Playoffs: - List every round played so far (Round 1 → current) instead of only the current round, on both macOS menu and iPhone - Strike through the eliminated team's tricode in a finished series and drop the now-redundant "(Final … wins)" tag on completed earlier rounds - Refetch the bracket when a finished game implies more completed games than the cached bracket records, so the series score and round no longer get stuck on stale data after cold launch or the NHL bracket endpoint's lag API robustness: - Tolerate optional gameCenterLink/startTimeUTC on TBD playoff matchups so the scoreboard decode no longer aborts - Reject API state regressions via a monotonic FUT→…→OFF progression rank so a brief glitch can't downgrade a finished game back to "-:-"
99 lines
2.8 KiB
Swift
99 lines
2.8 KiB
Swift
//
|
|
// 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 roundSeriesGroups: [MainService.RoundGroup] {
|
|
_ = revision
|
|
return MainService.shared.roundSeriesGroups
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|