Phase 1 - Focus & Typography: New TVFocusButtonStyle with 1.04x scale +
white glow border on focus, 0.97x press. Enforced 22px minimum text on
tvOS across DesignSystem (tvCaption 22px, tvBody 24px, tvDataValue 24px).
DataLabelStyle uses tvOS caption with reduced kerning.
Phase 2 - Today Tab: FeaturedGameCard redesigned as full-bleed hero with
away team left, home team right, 96pt score centered, DiamondView for
live count/outs. Removed side panel, replaced with single subtitle row.
GameCardView redesigned as horizontal score-bug style (~120px tall vs
320px) with team color accent bar, stacked logos, centered score, inline
mini linescore. Both show record + streak on every card.
Phase 3 - Intel Tab: Side-by-side layout on tvOS with standings
horizontal scroll on left (60%) and leaders vertical column on right
(40%). Both visible without scrolling past each other. iOS keeps the
stacked layout.
Phase 4 - Feed: Cards now horizontal with thumbnail left (300px tvOS),
info right. Added timestamp ("2h ago") to every card. All text meets
22px minimum on tvOS. Condensed game badge uses larger font.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
773 lines
29 KiB
Swift
773 lines
29 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)
|
|
}
|
|
|
|
#if os(tvOS)
|
|
// Side-by-side: standings left, leaders right
|
|
HStack(alignment: .top, spacing: 24) {
|
|
standingsSection
|
|
.frame(maxWidth: .infinity)
|
|
|
|
if !viewModel.leagueLeaders.isEmpty {
|
|
leadersColumnSection
|
|
.frame(width: 420)
|
|
}
|
|
}
|
|
#else
|
|
standingsSection
|
|
|
|
if !viewModel.leagueLeaders.isEmpty {
|
|
leadersSection
|
|
}
|
|
#endif
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
private var leadersColumnSection: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
HStack {
|
|
Text("Leaders")
|
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
|
|
Spacer()
|
|
|
|
if viewModel.isLoadingLeaders {
|
|
ProgressView()
|
|
}
|
|
}
|
|
|
|
ScrollView {
|
|
LazyVStack(spacing: DS.Spacing.cardGap) {
|
|
ForEach(viewModel.leagueLeaders.prefix(4)) { category in
|
|
LeaderboardView(category: category)
|
|
.platformFocusable()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|