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,14 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// IceGlass-iOS
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
MainView()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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