import SwiftUI struct LeagueCenterView: View { @Environment(GamesViewModel.self) private var gamesViewModel @State private var viewModel = LeagueCenterViewModel() @State private var selectedGame: Game? private let rosterColumns = [ GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14), GridItem(.flexible(), spacing: 14), ] 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, 56) .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) } .buttonStyle(.card) .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) } .focusSection() .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) ) } .buttonStyle(.card) } } .padding(.vertical, 4) } .focusSection() .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)) .focusable(true) } } } 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) } .buttonStyle(.card) } } } } 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)) .focusable(true) } } } 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()) } .buttonStyle(.card) } 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) } } }