Files
MLBApp/mlbTVOS/Views/LeagueCenterView.swift
Trey t fda809fd2f Add iOS/iPad target with platform-adaptive UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:30:28 -05:00

680 lines
26 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)
}
scheduleSection
standingsSection
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("Schedules, standings, 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.scheduleGames.count)", label: "Games", 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: 18) {
teamMiniColumn(team: game.teams.away)
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: 160)
teamMiniColumn(team: game.teams.home, alignTrailing: true)
Spacer()
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))
}
}
.padding(22)
.background(sectionPanel)
}
.platformCardStyle()
.disabled(linkedGame == nil)
}
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)
}
}
.frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading)
}
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) {
HStack(spacing: 18) {
ForEach(viewModel.standings, id: \.division?.id) { record in
standingsCard(record)
.frame(width: 360)
}
}
.padding(.vertical, 4)
}
.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)
}
}
}