Files
MLBApp/mlbTVOS/Views/LeagueCenterView.swift
Trey t cd605d889d Replace Feed with highlights, remove duplicate live shelf, drop Intel schedule
Feed tab: Replaced news/transaction feed with league-wide highlights and
condensed game replays. FeedViewModel now fetches highlights from all
games concurrently, splits into condensed games vs individual highlights.
Cards show team color thumbnails with play button overlay.

Today tab: Removed duplicate Live games shelf — LiveSituationBar already
shows all live games at the top, so the shelf was redundant.

Intel tab: Removed schedule section (already on Today tab). Updated
header description and stat pills. Added division hydration to standings
API call so division names display correctly instead of "Division".

Focus: Added .platformFocusable() to standings cards and leaderboard
cards so tvOS remote can scroll horizontally through them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:25:36 -05:00

734 lines
28 KiB
Swift

import SwiftUI
struct LeagueCenterView: View {
@Environment(GamesViewModel.self) private var gamesViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = LeagueCenterViewModel()
@State private var selectedGame: Game?
private var rosterColumns: [GridItem] {
let columnCount: Int
#if os(iOS)
columnCount = horizontalSizeClass == .compact ? 1 : 2
#else
columnCount = 3
#endif
return Array(repeating: GridItem(.flexible(), spacing: 14), count: columnCount)
}
private var horizontalPadding: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 20 : 32
#else
56
#endif
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 34) {
header
if let overviewErrorMessage = viewModel.overviewErrorMessage {
messagePanel(overviewErrorMessage, tint: .orange)
}
standingsSection
// League Leaders
if !viewModel.leagueLeaders.isEmpty {
leadersSection
}
teamsSection
if let selectedTeam = viewModel.selectedTeam {
teamProfileSection(team: selectedTeam)
} else if let teamErrorMessage = viewModel.teamErrorMessage {
messagePanel(teamErrorMessage, tint: .orange)
}
if !viewModel.roster.isEmpty {
rosterSection
}
if let player = viewModel.selectedPlayer {
playerSection(player: player)
} else if let playerErrorMessage = viewModel.playerErrorMessage {
messagePanel(playerErrorMessage, tint: .orange)
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, 40)
}
.background(screenBackground.ignoresSafeArea())
.task {
await viewModel.loadInitial()
}
.sheet(item: $selectedGame) { game in
StreamOptionsSheet(game: game)
}
}
private var header: some View {
HStack(alignment: .top, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text("Around MLB")
.font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Standings, league leaders, team context, roster access, and player snapshots in one control room.")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
}
Spacer()
HStack(spacing: 12) {
infoPill(title: "\(viewModel.leagueLeaders.count)", label: "Leaders", color: .blue)
infoPill(title: "\(viewModel.teams.count)", label: "Teams", color: .green)
infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange)
}
}
}
private var scheduleSection: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("Schedule")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(viewModel.displayDateString)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
Spacer()
HStack(spacing: 12) {
dateButton(title: "Previous", systemImage: "chevron.left") {
Task { await viewModel.goToPreviousDay() }
}
dateButton(title: "Today", systemImage: "calendar") {
Task { await viewModel.goToToday() }
}
dateButton(title: "Next", systemImage: "chevron.right") {
Task { await viewModel.goToNextDay() }
}
}
}
if viewModel.scheduleGames.isEmpty && viewModel.isLoadingOverview {
loadingPanel(title: "Loading schedule...")
} else {
VStack(spacing: 14) {
ForEach(viewModel.scheduleGames.prefix(10)) { game in
scheduleRow(game)
}
}
}
}
}
private func scheduleRow(_ game: StatsGame) -> some View {
let linkedGame = gamesViewModel.games.first { $0.gamePk == String(game.gamePk) }
return Button {
if let linkedGame {
selectedGame = linkedGame
}
} label: {
HStack(spacing: 0) {
teamMiniColumn(team: game.teams.away)
.frame(width: scheduleTeamColWidth, alignment: .leading)
VStack(spacing: 6) {
Text(scoreText(for: game))
.font(.system(size: 28, weight: .black, design: .rounded))
.foregroundStyle(.white)
.monospacedDigit()
Text(statusText(for: game))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(statusColor(for: game))
}
.frame(width: scheduleScoreColWidth)
teamMiniColumn(team: game.teams.home, alignTrailing: true)
.frame(width: scheduleTeamColWidth, alignment: .trailing)
VStack(alignment: .trailing, spacing: 6) {
if let venue = game.venue?.name {
Text(venue)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.56))
.lineLimit(1)
}
Text(linkedGame != nil ? "Open game sheet" : "Info only")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34))
}
.frame(width: scheduleVenueColWidth, alignment: .trailing)
}
.padding(22)
.background(sectionPanel)
}
.platformCardStyle()
.disabled(linkedGame == nil)
}
#if os(tvOS)
private var scheduleTeamColWidth: CGFloat { 340 }
private var scheduleScoreColWidth: CGFloat { 160 }
private var scheduleVenueColWidth: CGFloat { 220 }
#else
private var scheduleTeamColWidth: CGFloat { 200 }
private var scheduleScoreColWidth: CGFloat { 120 }
private var scheduleVenueColWidth: CGFloat { 160 }
#endif
private func teamMiniColumn(team: StatsTeamGameInfo, alignTrailing: Bool = false) -> some View {
let info = TeamInfo(
code: team.team.abbreviation ?? "MLB",
name: team.team.name ?? "MLB",
score: team.score,
teamId: team.team.id,
record: team.leagueRecord.map { "\($0.wins)-\($0.losses)" }
)
return HStack(spacing: 12) {
if !alignTrailing {
TeamLogoView(team: info, size: 56)
}
VStack(alignment: alignTrailing ? .trailing : .leading, spacing: 6) {
Text(info.code)
.font(.system(size: 22, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text(info.displayName)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
if let record = info.record {
Text(record)
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.46))
}
}
if alignTrailing {
TeamLogoView(team: info, size: 56)
}
}
}
private var leadersSection: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
Text("League Leaders")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Spacer()
if viewModel.isLoadingLeaders {
ProgressView()
}
}
ScrollView(.horizontal) {
LazyHStack(spacing: DS.Spacing.cardGap) {
ForEach(viewModel.leagueLeaders) { category in
LeaderboardView(category: category)
.frame(width: leaderCardWidth)
.platformFocusable()
}
}
.padding(.vertical, 8)
}
.platformFocusSection()
.scrollClipDisabled()
}
}
private var leaderCardWidth: CGFloat {
#if os(tvOS)
380
#else
280
#endif
}
private var standingsSection: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Standings")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if viewModel.standings.isEmpty && viewModel.isLoadingOverview {
loadingPanel(title: "Loading standings...")
} else {
ScrollView(.horizontal) {
LazyHStack(spacing: 18) {
ForEach(viewModel.standings, id: \.division?.id) { record in
standingsCard(record)
.frame(width: 360)
.platformFocusable()
}
}
.padding(.vertical, 8)
}
.platformFocusSection()
.scrollClipDisabled()
}
}
}
private func standingsCard(_ record: StandingsDivisionRecord) -> some View {
VStack(alignment: .leading, spacing: 14) {
Text(record.division?.name ?? "Division")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(.white)
VStack(spacing: 10) {
ForEach(record.teamRecords.sorted(by: standingsSort), id: \.team.id) { team in
HStack(spacing: 10) {
Text(team.divisionRank ?? "-")
.font(.system(size: 12, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.52))
.frame(width: 22)
Text(team.team.abbreviation ?? team.team.name ?? "MLB")
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Spacer()
if let wins = team.wins, let losses = team.losses {
Text("\(wins)-\(losses)")
.font(.system(size: 15, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.86))
}
Text(team.gamesBack ?? "-")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.45))
.frame(width: 44, alignment: .trailing)
}
.padding(.vertical, 4)
}
}
}
.padding(22)
.background(sectionPanel)
}
private var teamsSection: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Teams")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
ScrollView(.horizontal) {
HStack(spacing: 16) {
ForEach(viewModel.teams) { team in
Button {
Task { await viewModel.selectTeam(team.id) }
} label: {
VStack(alignment: .leading, spacing: 14) {
TeamLogoView(team: team.teamInfo, size: 72)
VStack(alignment: .leading, spacing: 6) {
Text(team.abbreviation)
.font(.system(size: 20, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text(team.name)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.76))
.lineLimit(2)
}
Spacer()
Text(team.recordText ?? "Season")
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.5))
}
.frame(width: 210, height: 220, alignment: .leading)
.padding(18)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(TeamAssets.color(for: team.abbreviation).opacity(0.16))
)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(.white.opacity(0.08), lineWidth: 1)
)
}
.platformCardStyle()
}
}
.padding(.vertical, 4)
}
.platformFocusSection()
.scrollClipDisabled()
}
}
private func teamProfileSection(team: TeamProfile) -> some View {
VStack(alignment: .leading, spacing: 18) {
Text("Team Profile")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if viewModel.isLoadingTeam {
loadingPanel(title: "Loading team profile...")
} else {
HStack(alignment: .top, spacing: 24) {
TeamLogoView(team: team.teamInfo, size: 96)
VStack(alignment: .leading, spacing: 12) {
Text(team.name)
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(.white)
HStack(spacing: 10) {
detailChip(team.recordText ?? "Season", color: .blue)
if let gamesBack = team.gamesBackText {
detailChip(gamesBack, color: .orange)
}
if let division = team.divisionName {
detailChip(division, color: .green)
}
}
VStack(alignment: .leading, spacing: 8) {
profileLine(label: "Venue", value: team.venueName)
profileLine(label: "League", value: team.leagueName)
profileLine(label: "Franchise", value: team.franchiseName)
profileLine(label: "First Year", value: team.firstYearOfPlay)
}
}
Spacer()
}
.padding(24)
.background(sectionPanel)
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.platformFocusable()
}
}
}
private var rosterSection: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Roster")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
LazyVGrid(columns: rosterColumns, spacing: 14) {
ForEach(viewModel.roster) { player in
Button {
Task { await viewModel.selectPlayer(player.id) }
} label: {
HStack(spacing: 14) {
playerHeadshot(url: player.headshotURL, size: 58)
VStack(alignment: .leading, spacing: 6) {
Text(player.fullName)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
.lineLimit(2)
Text("\(player.positionAbbreviation ?? "P") · #\(player.jerseyNumber ?? "--")")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
}
.padding(16)
.background(sectionPanel)
}
.platformCardStyle()
}
}
}
}
private func playerSection(player: PlayerProfile) -> some View {
VStack(alignment: .leading, spacing: 18) {
Text("Player Profile")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if viewModel.isLoadingPlayer {
loadingPanel(title: "Loading player profile...")
} else {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 24) {
playerHeadshot(url: player.headshotURL, size: 112)
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
if let primaryNumber = player.primaryNumber {
Text("#\(primaryNumber)")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
}
if let position = player.primaryPosition {
detailChip(position, color: .blue)
}
}
Text(player.fullName)
.font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: 8) {
profileLine(label: "Age", value: player.currentAge.map(String.init))
profileLine(label: "Bats / Throws", value: [player.bats, player.throwsHand].compactMap { $0 }.joined(separator: " / "))
profileLine(label: "Height / Weight", value: [player.height, player.weight.map { "\($0) lbs" }].compactMap { $0 }.joined(separator: " / "))
profileLine(label: "Born", value: player.birthPlace)
profileLine(label: "Debut", value: player.debutDate)
}
}
Spacer()
}
if !player.statGroups.isEmpty {
HStack(alignment: .top, spacing: 16) {
ForEach(player.statGroups) { group in
VStack(alignment: .leading, spacing: 14) {
Text("\(group.title) \(player.seasonLabel)")
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: 10) {
ForEach(group.items, id: \.label) { item in
HStack {
Text(item.label)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
Spacer()
Text(item.value)
.font(.system(size: 18, weight: .black, design: .rounded))
.foregroundStyle(.white)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.white.opacity(0.05))
)
}
}
} else {
Text("No regular-season MLB stats available for \(player.seasonLabel).")
.font(.system(size: 18, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 4)
}
}
.padding(24)
.background(sectionPanel)
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.platformFocusable()
}
}
}
private func playerHeadshot(url: URL, size: CGFloat) -> some View {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
default:
ZStack {
Circle()
.fill(.white.opacity(0.08))
Image(systemName: "person.fill")
.font(.system(size: size * 0.34, weight: .bold))
.foregroundStyle(.white.opacity(0.32))
}
}
}
.frame(width: size, height: size)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.white.opacity(0.08), lineWidth: 1)
)
}
private func profileLine(label: String, value: String?) -> some View {
let displayValue: String
if let value, !value.isEmpty {
displayValue = value
} else {
displayValue = "Unavailable"
}
return HStack(spacing: 10) {
Text(label)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.45))
.frame(width: 92, alignment: .leading)
Text(displayValue)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
}
}
private func messagePanel(_ text: String, tint: Color) -> some View {
Text(text)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(tint.opacity(0.95))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(sectionPanel)
}
private func detailChip(_ title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(color.opacity(0.95))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private func infoPill(title: String, label: String, color: Color) -> some View {
VStack(spacing: 4) {
Text(title)
.font(.system(size: 22, weight: .black, design: .rounded))
.foregroundStyle(color)
.monospacedDigit()
Text(label)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
}
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(sectionPanel)
}
private func dateButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: systemImage)
.font(.system(size: 12, weight: .bold))
Text(title)
.font(.system(size: 14, weight: .semibold))
}
.foregroundStyle(.white.opacity(0.84))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
.platformCardStyle()
}
private func loadingPanel(title: String) -> some View {
HStack {
Spacer()
ProgressView(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.75))
Spacer()
}
.padding(.vertical, 34)
.background(sectionPanel)
}
private func scoreText(for game: StatsGame) -> String {
if game.isScheduled {
return game.startTime ?? "TBD"
}
let away = game.teams.away.score ?? 0
let home = game.teams.home.score ?? 0
return "\(away) - \(home)"
}
private func statusText(for game: StatsGame) -> String {
if game.isLive {
return game.linescore?.currentInningDisplay ?? "Live"
}
if game.isFinal {
return "Final"
}
return game.status.detailedState ?? "Scheduled"
}
private func statusColor(for game: StatsGame) -> Color {
if game.isLive {
return .red.opacity(0.9)
}
if game.isFinal {
return .white.opacity(0.72)
}
return .blue.opacity(0.9)
}
private func standingsSort(lhs: StandingsTeamRecord, rhs: StandingsTeamRecord) -> Bool {
Int(lhs.divisionRank ?? "99") ?? 99 < Int(rhs.divisionRank ?? "99") ?? 99
}
private var sectionPanel: 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)
}
}
private var screenBackground: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.04, green: 0.05, blue: 0.09),
Color(red: 0.03, green: 0.06, blue: 0.1),
Color(red: 0.02, green: 0.03, blue: 0.06),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(.blue.opacity(0.18))
.frame(width: 520, height: 520)
.blur(radius: 110)
.offset(x: -360, y: -260)
Circle()
.fill(.orange.opacity(0.16))
.frame(width: 560, height: 560)
.blur(radius: 120)
.offset(x: 420, y: -80)
}
}
}