Files
MLBApp/mlbTVOS/Views/FeaturedGameCard.swift
Trey t 310d857c7f Fix horizontal overflow: shrink control rail, hero padding, title font
Everything was clipping off the left edge because the hero card + Live
Radar sidebar + padding exceeded screen width. Reduced control rail from
420px to 340px, hero internal padding from 48px to 40px, detail panel
from 360px to 300px, title font from 52pt to 44pt, hero height from
560px to 500px.

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

521 lines
19 KiB
Swift

import SwiftUI
struct FeaturedGameCard: View {
let game: Game
let onSelect: () -> Void
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
private var awayPitcherName: String? {
game.pitchers?.components(separatedBy: " vs ").first
}
private var homePitcherName: String? {
let parts = game.pitchers?.components(separatedBy: " vs ") ?? []
return parts.count > 1 ? parts.last : nil
}
private var heroImageURL: URL? {
if let pitcherId = game.homePitcherId {
return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_1400,q_auto:best/v1/people/\(pitcherId)/action/hero/current")
}
if let pitcherId = game.awayPitcherId {
return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_1400,q_auto:best/v1/people/\(pitcherId)/action/hero/current")
}
if let teamId = game.homeTeam.teamId {
return URL(string: "https://midfield.mlbstatic.com/v1/team/\(teamId)/spots/1200")
}
return nil
}
var body: some View {
Button(action: onSelect) {
ZStack(alignment: .topLeading) {
backgroundLayer
VStack(alignment: .leading, spacing: contentSpacing) {
headerRow
HStack(alignment: .bottom, spacing: 28) {
VStack(alignment: .leading, spacing: 16) {
scoreboardRow(team: game.awayTeam, isLeading: isWinning(away: true))
scoreboardRow(team: game.homeTeam, isLeading: isWinning(away: false))
}
.frame(maxWidth: .infinity, alignment: .leading)
detailPanel
.frame(width: detailPanelWidth, alignment: .trailing)
}
insightStrip
}
.padding(.horizontal, heroPadH)
.padding(.vertical, heroPadV)
}
.frame(maxWidth: .infinity)
.frame(height: heroHeight)
.clipShape(RoundedRectangle(cornerRadius: heroRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: heroRadius, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)
)
.shadow(color: .black.opacity(0.32), radius: 36, y: 18)
}
.platformCardStyle()
}
private var backgroundLayer: some View {
ZStack {
RoundedRectangle(cornerRadius: heroRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
DS.Colors.panelFill,
DS.Colors.backgroundElevated,
Color.black.opacity(0.94),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
// Team color wash gradient instead of blur for performance
LinearGradient(
colors: [
awayColor.opacity(0.2),
.clear,
homeColor.opacity(0.18),
],
startPoint: .leading,
endPoint: .trailing
)
heroImage
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
LinearGradient(
colors: [
Color.black.opacity(0.92),
Color.black.opacity(0.75),
Color.black.opacity(0.4),
Color.black.opacity(0.15),
],
startPoint: .leading,
endPoint: .trailing
)
}
LinearGradient(
colors: [
Color.black.opacity(0.16),
Color.black.opacity(0.52),
],
startPoint: .top,
endPoint: .bottom
)
}
}
private var headerRow: some View {
HStack(alignment: .top, spacing: 20) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
statusBadge
if let gameType = game.gameType, !gameType.isEmpty {
metaBadge(gameType.uppercased(), tint: DS.Colors.media)
}
if game.hasStreams {
metaBadge("\(game.broadcasts.count) FEED\(game.broadcasts.count == 1 ? "" : "S")", tint: DS.Colors.interactive)
}
}
Text("Featured Matchup")
.font(labelFont)
.foregroundStyle(DS.Colors.textTertiary)
.tracking(1.8)
Text(game.displayTitle)
.font(titleFont)
.foregroundStyle(DS.Colors.onDarkPrimary)
.lineLimit(2)
.minimumScaleFactor(0.7)
}
Spacer(minLength: 16)
HStack(spacing: 12) {
if let venue = game.venue {
summaryTag(value: venue, systemImage: "mappin.and.ellipse")
}
if game.isBlackedOut {
summaryTag(value: "Blackout", systemImage: "eye.slash.fill")
} else if game.hasStreams {
summaryTag(value: "Watch Now", systemImage: "play.fill")
}
}
}
}
private func scoreboardRow(team: TeamInfo, isLeading: Bool) -> some View {
HStack(spacing: 16) {
TeamLogoView(team: team, size: logoSize)
VStack(alignment: .leading, spacing: 5) {
Text(team.code)
.font(codeFont)
.foregroundStyle(.white)
Text(team.displayName)
.font(nameFont)
.foregroundStyle(DS.Colors.onDarkSecondary)
HStack(spacing: 10) {
if let record = team.record {
Text(record)
.font(metadataFont)
.foregroundStyle(DS.Colors.onDarkSecondary)
}
if let summary = team.standingSummary {
Text(summary)
.font(metadataFont)
.foregroundStyle(DS.Colors.onDarkTertiary)
.lineLimit(1)
}
}
}
Spacer(minLength: 8)
Text(team.score.map(String.init) ?? "")
.font(scoreFont)
.foregroundStyle(isLeading ? .white : DS.Colors.onDarkSecondary)
.monospacedDigit()
}
.padding(.horizontal, rowPadH)
.padding(.vertical, rowPadV)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color.black.opacity(isLeading ? 0.34 : 0.22))
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(Color.white.opacity(isLeading ? 0.10 : 0.06), lineWidth: 1)
}
)
}
@ViewBuilder
private var detailPanel: some View {
VStack(alignment: .leading, spacing: 16) {
switch game.status {
case .live:
livePanel
case .final_:
finalPanel
case .scheduled:
scheduledPanel
case .unknown:
statusFallbackPanel
}
}
.padding(detailPanelPad)
.background(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(Color.black.opacity(0.34))
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)
}
)
}
private var livePanel: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Live Situation")
.font(panelLabelFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(game.currentInningDisplay ?? "Live")
.font(panelValueFont)
.foregroundStyle(.white)
if let linescore = game.linescore {
DiamondView(
balls: linescore.balls ?? 0,
strikes: linescore.strikes ?? 0,
outs: linescore.outs ?? 0
)
if let awayRuns = linescore.teams?.away?.runs,
let homeRuns = linescore.teams?.home?.runs,
let awayHits = linescore.teams?.away?.hits,
let homeHits = linescore.teams?.home?.hits {
HStack(spacing: 14) {
detailMetric(label: game.awayTeam.code, value: "\(awayRuns)R / \(awayHits)H")
detailMetric(label: game.homeTeam.code, value: "\(homeRuns)R / \(homeHits)H")
}
}
}
}
}
private var finalPanel: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Final")
.font(panelLabelFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(game.scoreDisplay ?? "Complete")
.font(panelValueFont)
.foregroundStyle(.white)
Text("Box score, play timeline, and highlights are ready in Game Center.")
.font(panelBodyFont)
.foregroundStyle(DS.Colors.onDarkSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var scheduledPanel: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Starting Pitchers")
.font(panelLabelFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(pitcherMatchupText)
.font(panelValueFont)
.foregroundStyle(.white)
.lineLimit(3)
if let startTime = game.startTime {
Text("First pitch \(startTime)")
.font(panelBodyFont)
.foregroundStyle(DS.Colors.onDarkSecondary)
}
}
}
private var statusFallbackPanel: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Game State")
.font(panelLabelFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(game.status.label.isEmpty ? "Awaiting update" : game.status.label)
.font(panelValueFont)
.foregroundStyle(.white)
}
}
private var insightStrip: some View {
HStack(spacing: 14) {
insightCard(
title: "Pitching",
value: pitcherInsightText,
accent: DS.Colors.media
)
insightCard(
title: "Venue",
value: game.venue ?? "TBD",
accent: DS.Colors.interactive
)
insightCard(
title: "Feeds",
value: game.isBlackedOut ? "Blackout" : "\(game.broadcasts.count) available",
accent: game.isBlackedOut ? DS.Colors.live : DS.Colors.positive
)
}
}
private func insightCard(title: String, value: String, accent: Color) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(insightTitleFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(value)
.font(insightValueFont)
.foregroundStyle(.white)
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(accent.opacity(0.14))
.overlay {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(accent.opacity(0.20), lineWidth: 1)
}
)
}
private func detailMetric(label: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(insightTitleFont)
.foregroundStyle(DS.Colors.textTertiary)
Text(value)
.font(insightValueFont)
.foregroundStyle(.white)
}
}
private func summaryTag(value: String, systemImage: String) -> some View {
Label(value, systemImage: systemImage)
.font(summaryFont)
.foregroundStyle(.white.opacity(0.92))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
Capsule()
.fill(Color.black.opacity(0.28))
.overlay {
Capsule()
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)
}
)
}
private func metaBadge(_ value: String, tint: Color) -> some View {
Text(value)
.font(badgeFont)
.foregroundStyle(tint)
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(
Capsule()
.fill(tint.opacity(0.14))
.overlay {
Capsule()
.strokeBorder(tint.opacity(0.22), lineWidth: 1)
}
)
}
@ViewBuilder
private var statusBadge: some View {
switch game.status {
case .live(let inning):
metaBadge(inning?.uppercased() ?? "LIVE", tint: DS.Colors.live)
case .scheduled(let time):
metaBadge(time.uppercased(), tint: DS.Colors.warning)
case .final_:
metaBadge("FINAL", tint: DS.Colors.positive)
case .unknown:
metaBadge("PENDING", tint: DS.Colors.textTertiary)
}
}
@ViewBuilder
private var heroImage: some View {
if let url = heroImageURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
default:
fallbackImage
}
}
} else {
fallbackImage
}
}
private var fallbackImage: some View {
LinearGradient(
colors: [
awayColor.opacity(0.32),
homeColor.opacity(0.28),
Color.clear,
],
startPoint: .leading,
endPoint: .trailing
)
}
private var pitcherMatchupText: String {
if let awayPitcherName, let homePitcherName {
return "\(awayPitcherName)\nvs \(homePitcherName)"
}
return game.pitchers ?? "Pitchers pending"
}
private var pitcherInsightText: String {
if let awayPitcherName, let homePitcherName {
return "\(awayPitcherName) vs \(homePitcherName)"
}
return game.pitchers ?? "Awaiting starters"
}
private func isWinning(away: Bool) -> Bool {
guard let awayScore = game.awayTeam.score, let homeScore = game.homeTeam.score else {
return false
}
return away ? awayScore > homeScore : homeScore > awayScore
}
#if os(tvOS)
private var heroHeight: CGFloat { 500 }
private var heroRadius: CGFloat { 34 }
private var heroPadH: CGFloat { 40 }
private var heroPadV: CGFloat { 36 }
private var detailPanelWidth: CGFloat { 300 }
private var detailPanelPad: CGFloat { 26 }
private var detailPanelWidthCompact: CGFloat { 320 }
private var contentSpacing: CGFloat { 26 }
private var logoSize: CGFloat { 56 }
private var rowPadH: CGFloat { 22 }
private var rowPadV: CGFloat { 18 }
private var titleFont: Font { .system(size: 44, weight: .black, design: .rounded) }
private var labelFont: Font { .system(size: 15, weight: .black, design: .rounded) }
private var codeFont: Font { .system(size: 22, weight: .black, design: .rounded) }
private var nameFont: Font { .system(size: 28, weight: .bold, design: .rounded) }
private var metadataFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
private var scoreFont: Font { .system(size: 60, weight: .black, design: .rounded).monospacedDigit() }
private var badgeFont: Font { .system(size: 13, weight: .black, design: .rounded) }
private var summaryFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
private var panelLabelFont: Font { .system(size: 14, weight: .black, design: .rounded) }
private var panelValueFont: Font { .system(size: 28, weight: .black, design: .rounded) }
private var panelBodyFont: Font { .system(size: 18, weight: .semibold) }
private var insightTitleFont: Font { .system(size: 13, weight: .black, design: .rounded) }
private var insightValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
#else
private var heroHeight: CGFloat { 340 }
private var heroRadius: CGFloat { 26 }
private var heroPadH: CGFloat { 22 }
private var heroPadV: CGFloat { 22 }
private var detailPanelWidth: CGFloat { 250 }
private var detailPanelPad: CGFloat { 18 }
private var detailPanelWidthCompact: CGFloat { 240 }
private var contentSpacing: CGFloat { 18 }
private var logoSize: CGFloat { 36 }
private var rowPadH: CGFloat { 14 }
private var rowPadV: CGFloat { 12 }
private var titleFont: Font { .system(size: 30, weight: .black, design: .rounded) }
private var labelFont: Font { .system(size: 11, weight: .black, design: .rounded) }
private var codeFont: Font { .system(size: 15, weight: .black, design: .rounded) }
private var nameFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
private var metadataFont: Font { .system(size: 12, weight: .bold, design: .rounded) }
private var scoreFont: Font { .system(size: 32, weight: .black, design: .rounded).monospacedDigit() }
private var badgeFont: Font { .system(size: 10, weight: .black, design: .rounded) }
private var summaryFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
private var panelLabelFont: Font { .system(size: 10, weight: .black, design: .rounded) }
private var panelValueFont: Font { .system(size: 18, weight: .black, design: .rounded) }
private var panelBodyFont: Font { .system(size: 13, weight: .semibold) }
private var insightTitleFont: Font { .system(size: 10, weight: .black, design: .rounded) }
private var insightValueFont: Font { .system(size: 12, weight: .bold, design: .rounded) }
#endif
}