Stadium venue URLs return 404. Switched to pitcher action hero photos from img.mlbstatic.com which return real high-res player photos — much more impactful than stadiums anyway (matches the cast photo aesthetic from the reference). Falls back to prominent team logos with rich team color gradients instead of washed-out gray circles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
336 lines
12 KiB
Swift
336 lines
12 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? {
|
|
// Prefer pitcher action hero photo — big, dramatic, like a cast photo
|
|
if let pitcherId = game.homePitcherId {
|
|
return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_1200,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_1200,q_auto:best/v1/people/\(pitcherId)/action/hero/current")
|
|
}
|
|
// Fall back to large team logo
|
|
if let teamId = game.homeTeam.teamId {
|
|
return URL(string: "https://midfield.mlbstatic.com/v1/team/\(teamId)/spots/800")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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) {
|
|
heroImage
|
|
.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 heroImage: some View {
|
|
if let url = heroImageURL {
|
|
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 {
|
|
// Rich team color gradient
|
|
LinearGradient(
|
|
colors: [
|
|
awayColor.opacity(0.4),
|
|
homeColor.opacity(0.6),
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
// Large prominent team logos
|
|
HStack(spacing: fallbackLogoGap) {
|
|
TeamLogoView(team: game.awayTeam, size: fallbackLogoSize)
|
|
.shadow(color: .black.opacity(0.2), radius: 12, y: 4)
|
|
Text("vs")
|
|
.font(.system(size: fallbackLogoSize * 0.3, weight: .light))
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
TeamLogoView(team: game.homeTeam, size: fallbackLogoSize)
|
|
.shadow(color: .black.opacity(0.2), radius: 12, y: 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
|
|
}
|