Files
MLBApp/mlbTVOS/Views/FeaturedGameCard.swift
Trey t 346557af88 Fix hero to match reference: white surface, image right, outlined CTA
Completely rebuilt FeaturedGameCard to match the Dribbble vtv reference.
White/cream background surface instead of dark card. Stadium image sits
on the right side and fades into white via left-to-right gradient. Dark
text on light background. "Watch Now" as outlined orange pill (not
filled). Plus icon for add-to-multiview.

Title uses split weight: thin "Houston Astros vs" + bold orange "Seattle
Mariners" — mimicking the "modern family" typography split.

Cleaned up DashboardView header: removed bulky MLB/date/stat pills
section. Replaced with compact inline date nav: chevron left, date text,
chevron right, "Today" link, game count + live count on the right. One
line instead of a full section.

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

317 lines
11 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 stadiumImageURL: URL? {
TeamAssets.stadiumURL(for: game.homeTeam.code)
}
var body: some View {
Button(action: onSelect) {
ZStack(alignment: .topLeading) {
// White/cream base
DS.Colors.panelFill
// Stadium image on the right side, fading into white on the left
HStack(spacing: 0) {
Spacer()
ZStack(alignment: .leading) {
stadiumImage
.frame(width: imageWidth)
// White fade from left edge of image
LinearGradient(
colors: [
DS.Colors.panelFill,
DS.Colors.panelFill.opacity(0.8),
DS.Colors.panelFill.opacity(0.3),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: fadeWidth)
}
}
// Text content on the left
VStack(alignment: .leading, spacing: contentSpacing) {
// Status badge
statusBadge
// Giant matchup title thin "away" bold "home"
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 0) {
Text(game.awayTeam.displayName)
.font(titleThinFont)
.foregroundStyle(DS.Colors.textSecondary)
Text(" vs ")
.font(titleThinFont)
.foregroundStyle(DS.Colors.textTertiary)
}
Text(game.homeTeam.displayName)
.font(titleBoldFont)
.foregroundStyle(DS.Colors.interactive)
}
// Metadata line
metadataLine
// Live score or description
if game.isLive {
liveSection
} else if game.isFinal {
finalSection
} else {
scheduledSection
}
// CTA buttons
HStack(spacing: 14) {
if game.hasStreams {
Label("Watch Now", systemImage: "play.fill")
.font(ctaFont)
.foregroundStyle(DS.Colors.interactive)
.padding(.horizontal, ctaPadH)
.padding(.vertical, ctaPadV)
.overlay(
Capsule().strokeBorder(DS.Colors.interactive, lineWidth: 2)
)
}
Image(systemName: "plus")
.font(ctaFont)
.foregroundStyle(DS.Colors.textTertiary)
.padding(ctaPadV)
.overlay(
Circle().strokeBorder(DS.Colors.textQuaternary, lineWidth: 1.5)
)
}
}
.padding(.horizontal, heroPadH)
.padding(.vertical, heroPadV)
.frame(maxWidth: textAreaWidth, alignment: .topLeading)
}
.frame(height: heroHeight)
.clipShape(RoundedRectangle(cornerRadius: heroRadius, style: .continuous))
.shadow(color: .black.opacity(0.08), radius: 30, y: 12)
}
.platformCardStyle()
}
// MARK: - Live Section
@ViewBuilder
private var liveSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline, spacing: 16) {
if let away = game.awayTeam.score, let home = game.homeTeam.score {
Text("\(away) - \(home)")
.font(scoreFont)
.foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit()
.contentTransition(.numericText())
}
if let inning = game.currentInningDisplay {
Text(inning)
.font(inningFont)
.foregroundStyle(DS.Colors.live)
}
}
if let linescore = game.linescore {
DiamondView(
balls: linescore.balls ?? 0,
strikes: linescore.strikes ?? 0,
outs: linescore.outs ?? 0
)
}
}
}
// MARK: - Final Section
@ViewBuilder
private var finalSection: some View {
if let away = game.awayTeam.score, let home = game.homeTeam.score {
HStack(spacing: 12) {
Text("\(away) - \(home)")
.font(scoreFont)
.foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit()
Text("FINAL")
.font(inningFont)
.foregroundStyle(DS.Colors.textTertiary)
}
}
}
// MARK: - Scheduled Section
@ViewBuilder
private var scheduledSection: some View {
VStack(alignment: .leading, spacing: 6) {
if let pitchers = game.pitchers {
Text(pitchers)
.font(descFont)
.foregroundStyle(DS.Colors.textSecondary)
.lineLimit(2)
}
}
}
// MARK: - Status Badge
@ViewBuilder
private var statusBadge: some View {
switch game.status {
case .live(let inning):
HStack(spacing: 6) {
Circle().fill(DS.Colors.live).frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(badgeFont)
.foregroundStyle(DS.Colors.live)
}
case .scheduled(let time):
Text(time)
.font(badgeFont)
.foregroundStyle(DS.Colors.textTertiary)
case .final_:
Text("FINAL")
.font(badgeFont)
.foregroundStyle(DS.Colors.textTertiary)
case .unknown:
EmptyView()
}
}
// MARK: - Metadata Line
@ViewBuilder
private var metadataLine: some View {
HStack(spacing: metaSeparatorWidth) {
if let venue = game.venue {
Text(venue)
}
if let record = game.awayTeam.record {
Text("\(game.awayTeam.code) \(record)")
}
if let record = game.homeTeam.record {
Text("\(game.homeTeam.code) \(record)")
}
if !game.broadcasts.isEmpty {
Text("\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")")
}
}
.font(metaFont)
.foregroundStyle(DS.Colors.textTertiary)
}
// MARK: - Stadium Image
@ViewBuilder
private var stadiumImage: some View {
if let url = stadiumImageURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: heroHeight)
.clipped()
default:
fallbackImage
}
}
} else {
fallbackImage
}
}
@ViewBuilder
private var fallbackImage: some View {
ZStack {
LinearGradient(
colors: [awayColor.opacity(0.15), homeColor.opacity(0.15)],
startPoint: .leading,
endPoint: .trailing
)
HStack(spacing: fallbackLogoGap) {
TeamLogoView(team: game.awayTeam, size: fallbackLogoSize)
.opacity(0.4)
TeamLogoView(team: game.homeTeam, size: fallbackLogoSize)
.opacity(0.4)
}
}
}
// MARK: - Platform Sizing
#if os(tvOS)
private var heroHeight: CGFloat { 480 }
private var heroRadius: CGFloat { 28 }
private var heroPadH: CGFloat { 60 }
private var heroPadV: CGFloat { 50 }
private var contentSpacing: CGFloat { 16 }
private var imageWidth: CGFloat { 900 }
private var fadeWidth: CGFloat { 400 }
private var textAreaWidth: CGFloat { 700 }
private var metaSeparatorWidth: CGFloat { 18 }
private var fallbackLogoSize: CGFloat { 120 }
private var fallbackLogoGap: CGFloat { 40 }
private var titleThinFont: Font { .system(size: 48, weight: .light) }
private var titleBoldFont: Font { .system(size: 52, weight: .black, design: .rounded) }
private var scoreFont: Font { .system(size: 64, weight: .black, design: .rounded) }
private var inningFont: Font { .system(size: 28, weight: .bold, design: .rounded) }
private var badgeFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
private var metaFont: Font { .system(size: 22, weight: .medium) }
private var descFont: Font { .system(size: 24, weight: .medium) }
private var ctaFont: Font { .system(size: 24, weight: .bold) }
private var ctaPadH: CGFloat { 32 }
private var ctaPadV: CGFloat { 14 }
#else
private var heroHeight: CGFloat { 340 }
private var heroRadius: CGFloat { 22 }
private var heroPadH: CGFloat { 28 }
private var heroPadV: CGFloat { 28 }
private var contentSpacing: CGFloat { 10 }
private var imageWidth: CGFloat { 400 }
private var fadeWidth: CGFloat { 200 }
private var textAreaWidth: CGFloat { 350 }
private var metaSeparatorWidth: CGFloat { 12 }
private var fallbackLogoSize: CGFloat { 60 }
private var fallbackLogoGap: CGFloat { 20 }
private var titleThinFont: Font { .system(size: 28, weight: .light) }
private var titleBoldFont: Font { .system(size: 32, weight: .black, design: .rounded) }
private var scoreFont: Font { .system(size: 40, weight: .black, design: .rounded) }
private var inningFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
private var badgeFont: Font { .system(size: 14, weight: .bold, design: .rounded) }
private var metaFont: Font { .system(size: 14, weight: .medium) }
private var descFont: Font { .system(size: 15, weight: .medium) }
private var ctaFont: Font { .system(size: 16, weight: .bold) }
private var ctaPadH: CGFloat { 22 }
private var ctaPadV: CGFloat { 10 }
#endif
}