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
+14
View File
@@ -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()
}
}
+32
View File
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+34
View File
@@ -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()
}
}
+52
View File
@@ -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))"
}
}
+78
View File
@@ -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)
}
}
+82
View File
@@ -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))
}
}
}
+75
View File
@@ -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)
}
}
+54
View File
@@ -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() }
}
}
}
}
}
+81
View File
@@ -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"
}
}