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:
@@ -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))"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user