Initial commit
This commit is contained in:
375
mlbTVOS/Views/GameCenterView.swift
Normal file
375
mlbTVOS/Views/GameCenterView.swift
Normal file
@@ -0,0 +1,375 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GameCenterView: View {
|
||||
let game: Game
|
||||
|
||||
@State private var viewModel = GameCenterViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
header
|
||||
|
||||
if viewModel.isLoading && viewModel.feed == nil {
|
||||
loadingState
|
||||
} else if let feed = viewModel.feed {
|
||||
situationStrip(feed: feed)
|
||||
matchupPanel(feed: feed)
|
||||
|
||||
if !feed.decisionsSummary.isEmpty || !feed.officialSummary.isEmpty || feed.weatherSummary != nil {
|
||||
contextPanel(feed: feed)
|
||||
}
|
||||
|
||||
if !feed.awayLineup.isEmpty || !feed.homeLineup.isEmpty {
|
||||
lineupPanel(feed: feed)
|
||||
}
|
||||
|
||||
if !feed.scoringPlays.isEmpty {
|
||||
timelineSection(
|
||||
title: "Scoring Plays",
|
||||
subtitle: "Every run-scoring swing, plate appearance, and sequence.",
|
||||
plays: Array(feed.scoringPlays.suffix(6).reversed())
|
||||
)
|
||||
}
|
||||
|
||||
timelineSection(
|
||||
title: "Recent Plays",
|
||||
subtitle: "The latest plate appearances and game-state changes.",
|
||||
plays: Array(feed.recentPlays.prefix(8))
|
||||
)
|
||||
} else {
|
||||
errorState
|
||||
}
|
||||
}
|
||||
.task(id: game.id) {
|
||||
await viewModel.watch(game: game)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Game Center")
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Live matchup context, lineup state, and inning-by-inning events from MLB's live feed.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if let gamePk = game.gamePk {
|
||||
Task { await viewModel.refresh(gamePk: gamePk) }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
Text(refreshLabel)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.white.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.disabled(game.gamePk == nil)
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func situationStrip(feed: LiveGameFeed) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
situationTile(
|
||||
title: "Situation",
|
||||
value: feed.liveData.linescore?.inningDisplay ?? game.currentInningDisplay ?? game.status.label,
|
||||
accent: .red
|
||||
)
|
||||
|
||||
situationTile(
|
||||
title: "Count",
|
||||
value: feed.currentCountText ?? "No active count",
|
||||
accent: .blue
|
||||
)
|
||||
|
||||
situationTile(
|
||||
title: "Bases",
|
||||
value: feed.occupiedBases.isEmpty ? "Bases Empty" : "On \(feed.occupiedBases.joined(separator: ", "))",
|
||||
accent: .green
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func situationTile(title: String, value: String, accent: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(accent.opacity(0.9))
|
||||
.kerning(1.2)
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 19, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.82)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func matchupPanel(feed: LiveGameFeed) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("At Bat")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
|
||||
Text(feed.currentBatter?.displayName ?? "Awaiting matchup")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let onDeck = feed.onDeckBatter?.displayName {
|
||||
Text("On deck: \(onDeck)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
if let inHole = feed.inHoleBatter?.displayName {
|
||||
Text("In hole: \(inHole)")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.46))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 5) {
|
||||
Text("Pitching")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
|
||||
Text(feed.currentPitcher?.displayName ?? "Awaiting pitcher")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
if let play = feed.currentPlay {
|
||||
Text(play.summaryText)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
.frame(maxWidth: 420, alignment: .trailing)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func contextPanel(feed: LiveGameFeed) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if let weatherSummary = feed.weatherSummary {
|
||||
contextRow(title: "Weather", values: [weatherSummary], accent: .blue)
|
||||
}
|
||||
|
||||
if !feed.decisionsSummary.isEmpty {
|
||||
contextRow(title: "Decisions", values: feed.decisionsSummary, accent: .green)
|
||||
}
|
||||
|
||||
if !feed.officialSummary.isEmpty {
|
||||
contextRow(title: "Officials", values: Array(feed.officialSummary.prefix(4)), accent: .orange)
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func contextRow(title: String, values: [String], accent: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(accent.opacity(0.9))
|
||||
.kerning(1.2)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
ForEach(values, id: \.self) { value in
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(.white.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lineupPanel(feed: LiveGameFeed) -> some View {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
lineupColumn(
|
||||
title: game.awayTeam.code,
|
||||
teamName: game.awayTeam.displayName,
|
||||
color: TeamAssets.color(for: game.awayTeam.code),
|
||||
players: Array(feed.awayLineup.prefix(9))
|
||||
)
|
||||
|
||||
lineupColumn(
|
||||
title: game.homeTeam.code,
|
||||
teamName: game.homeTeam.displayName,
|
||||
color: TeamAssets.color(for: game.homeTeam.code),
|
||||
players: Array(feed.homeLineup.prefix(9))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func lineupColumn(title: String, teamName: String, color: Color, players: [LiveFeedBoxscorePlayer]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(teamName)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(Array(players.enumerated()), id: \.element.id) { index, player in
|
||||
HStack(spacing: 10) {
|
||||
Text("\(index + 1)")
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.frame(width: 18, alignment: .leading)
|
||||
|
||||
Text(player.person.displayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.88))
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(player.position?.abbreviation ?? "")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private func timelineSection(title: String, subtitle: String, plays: [LiveFeedPlay]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.55))
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(plays) { play in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(play.about?.halfInning?.uppercased() ?? "PLAY")
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
|
||||
if let inning = play.about?.inning {
|
||||
Text("\(inning)")
|
||||
.font(.system(size: 16, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(width: 54, alignment: .leading)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(play.summaryText)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let event = play.result?.event {
|
||||
Text(event)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(.white.opacity(0.05))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private var loadingState: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Loading game center...")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 28)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private var errorState: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Game center is unavailable.")
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(viewModel.errorMessage ?? "No live feed data was returned for this game.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
}
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
private var refreshLabel: String {
|
||||
if let lastUpdated = viewModel.lastUpdated {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "h:mm:ss a"
|
||||
return formatter.string(from: lastUpdated)
|
||||
}
|
||||
return "Refresh"
|
||||
}
|
||||
|
||||
private var panelBackground: some View {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(.black.opacity(0.22))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user