aaffa3771c
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.
104 lines
2.9 KiB
Swift
104 lines
2.9 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 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()
|
|
}
|
|
}
|