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.
This commit is contained in:
2026-04-25 06:34:36 -04:00
parent 18c4ef64d6
commit aaffa3771c
70 changed files with 1011 additions and 65 deletions
@@ -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()
}
}