Files
MLBApp/mlbTVOS/Views/FeaturedGameCard.swift
Trey t 65ad41840f Complete visual overhaul: warm light theme, stadium hero, inline pill nav
Inspired by vtv/Dribbble streaming concept. Every dark surface replaced
with warm off-white (#F5F3F0) and white cards with soft shadows.

Design System: Warm off-white background, white card fills, subtle drop
shadows, dark text hierarchy, warm orange accent (#F27326) replacing
blue as primary interactive color. Added onDark color variants for hero
overlays. Shadow system with card/lifted states.

Navigation: Replaced TabView with inline CategoryPillBar — horizontal
orange pills (Today | Intel | Highlights | Multi-View | Settings).
Single scrolling view, no system chrome. Multi-View as icon button
with stream count badge. Settings as gear icon.

Stadium Hero: Full-bleed stadium photos from MLB CDN
(mlbstatic.com/v1/venue/{id}/spots/1200) as featured game background.
Left gradient overlay for text readability. Live games show score +
inning + DiamondView count/outs. Scheduled games show probable pitchers
with headshots + records. Final games show final score. Warm orange
"Watch Now" CTA pill. Added venue ID mapping for all 30 stadiums to
TeamAssets.

Game Cards: White cards with team color top bar, horizontal team rows,
dark text, soft shadows. Record + streak on every card.

Intel Tab: All dark panels replaced with white cards + shadows.
Replaced dark gradient screen background with flat warm off-white.
58 hardcoded .white.opacity() values replaced with DS.Colors tokens.

Feed Tab: Already used DS.Colors — inherits light theme automatically.

Focus: tvOS focus style uses warm orange border highlight + lifted
shadow instead of white glow on dark.

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

328 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 stadiumImageURL: URL? {
TeamAssets.stadiumURL(for: game.homeTeam.code)
}
var body: some View {
Button(action: onSelect) {
ZStack(alignment: .leading) {
// Stadium photo background
stadiumBackground
// Left gradient overlay for text readability
HStack(spacing: 0) {
LinearGradient(
colors: [
Color(red: 0.08, green: 0.08, blue: 0.10),
Color(red: 0.08, green: 0.08, blue: 0.10).opacity(0.95),
Color(red: 0.08, green: 0.08, blue: 0.10).opacity(0.6),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(maxWidth: .infinity)
}
// Content overlay
HStack(alignment: .top, spacing: 0) {
heroContent
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, heroPadH)
.padding(.vertical, heroPadV)
Spacer(minLength: 0)
}
}
.frame(height: heroHeight)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.featured, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: DS.Radii.featured, style: .continuous)
.strokeBorder(game.isLive ? DS.Colors.live.opacity(0.3) : .white.opacity(0.1), lineWidth: game.isLive ? 2 : 1)
)
.shadow(color: .black.opacity(0.2), radius: 30, y: 12)
}
.platformCardStyle()
}
// MARK: - Hero Content (left side overlay)
@ViewBuilder
private var heroContent: some View {
VStack(alignment: .leading, spacing: heroContentSpacing) {
// Status badge
statusBadge
// Giant matchup title
VStack(alignment: .leading, spacing: 4) {
Text("\(game.awayTeam.code) vs \(game.homeTeam.code)")
.font(matchupFont)
.foregroundStyle(DS.Colors.onDarkPrimary)
Text("\(game.awayTeam.displayName) at \(game.homeTeam.displayName)")
.font(subtitleFont)
.foregroundStyle(DS.Colors.onDarkSecondary)
}
// Live data OR scheduled/final data
if game.isLive {
liveDataSection
} else if game.isFinal {
finalDataSection
} else {
scheduledDataSection
}
// Metadata line
metadataLine
// CTA
HStack(spacing: 14) {
if game.hasStreams {
Label("Watch Now", systemImage: "play.fill")
.font(ctaFont)
.foregroundStyle(.white)
.padding(.horizontal, ctaPadH)
.padding(.vertical, ctaPadV)
.background(DS.Colors.interactive)
.clipShape(Capsule())
}
}
}
}
// MARK: - Live Data
@ViewBuilder
private var liveDataSection: some View {
VStack(alignment: .leading, spacing: 16) {
// Score
HStack(alignment: .firstTextBaseline, spacing: 20) {
if let away = game.awayTeam.score, let home = game.homeTeam.score {
Text("\(away) - \(home)")
.font(liveScoreFont)
.foregroundStyle(DS.Colors.onDarkPrimary)
.monospacedDigit()
.contentTransition(.numericText())
}
if let inning = game.currentInningDisplay {
Text(inning)
.font(inningFont)
.foregroundStyle(DS.Colors.live)
}
}
// Count + outs diamond
if let linescore = game.linescore {
DiamondView(
balls: linescore.balls ?? 0,
strikes: linescore.strikes ?? 0,
outs: linescore.outs ?? 0
)
}
}
}
// MARK: - Final Data
@ViewBuilder
private var finalDataSection: some View {
if let away = game.awayTeam.score, let home = game.homeTeam.score {
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text("\(away) - \(home)")
.font(liveScoreFont)
.foregroundStyle(DS.Colors.onDarkPrimary)
.monospacedDigit()
Text("FINAL")
.font(inningFont)
.foregroundStyle(DS.Colors.onDarkTertiary)
}
}
}
// MARK: - Scheduled Data
@ViewBuilder
private var scheduledDataSection: some View {
VStack(alignment: .leading, spacing: 8) {
if let pitchers = game.pitchers {
HStack(spacing: 10) {
if let url = game.awayPitcherHeadshotURL {
PitcherHeadshotView(url: url, teamCode: game.awayTeam.code, name: nil, size: pitcherSize)
}
if let url = game.homePitcherHeadshotURL {
PitcherHeadshotView(url: url, teamCode: game.homeTeam.code, name: nil, size: pitcherSize)
}
Text(pitchers)
.font(pitcherFont)
.foregroundStyle(DS.Colors.onDarkSecondary)
.lineLimit(1)
}
}
HStack(spacing: 16) {
if let record = game.awayTeam.record {
Text("\(game.awayTeam.code) \(record)")
.font(recordFont)
.foregroundStyle(DS.Colors.onDarkTertiary)
}
if let record = game.homeTeam.record {
Text("\(game.homeTeam.code) \(record)")
.font(recordFont)
.foregroundStyle(DS.Colors.onDarkTertiary)
}
}
}
}
// 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(.white)
}
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(DS.Colors.live.opacity(0.3))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(badgeFont)
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.white.opacity(0.15))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(badgeFont)
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.white.opacity(0.15))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
// MARK: - Metadata
@ViewBuilder
private var metadataLine: some View {
HStack(spacing: 14) {
if let venue = game.venue {
Label(venue, systemImage: "mappin.and.ellipse")
.font(metaFont)
.foregroundStyle(DS.Colors.onDarkTertiary)
}
if !game.broadcasts.isEmpty {
Text("\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")")
.font(metaFont)
.foregroundStyle(DS.Colors.onDarkTertiary)
}
}
}
// MARK: - Stadium Background
@ViewBuilder
private var stadiumBackground: some View {
if let url = stadiumImageURL {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
fallbackBackground
default:
fallbackBackground
.redacted(reason: .placeholder)
}
}
} else {
fallbackBackground
}
}
@ViewBuilder
private var fallbackBackground: some View {
ZStack {
Color(red: 0.08, green: 0.08, blue: 0.10)
LinearGradient(
colors: [awayColor.opacity(0.3), homeColor.opacity(0.3)],
startPoint: .leading,
endPoint: .trailing
)
}
}
// MARK: - Platform Sizing
#if os(tvOS)
private var heroHeight: CGFloat { 500 }
private var heroPadH: CGFloat { 60 }
private var heroPadV: CGFloat { 50 }
private var heroContentSpacing: CGFloat { 18 }
private var pitcherSize: CGFloat { 40 }
private var matchupFont: Font { .system(size: 52, weight: .black, design: .rounded) }
private var subtitleFont: Font { .system(size: 26, weight: .semibold) }
private var liveScoreFont: Font { .system(size: 72, weight: .black, design: .rounded) }
private var inningFont: Font { .system(size: 28, weight: .bold, design: .rounded) }
private var badgeFont: Font { .system(size: 22, weight: .black, design: .rounded) }
private var ctaFont: Font { .system(size: 24, weight: .bold) }
private var ctaPadH: CGFloat { 32 }
private var ctaPadV: CGFloat { 14 }
private var pitcherFont: Font { .system(size: 24, weight: .semibold) }
private var recordFont: Font { .system(size: 22, weight: .bold, design: .monospaced) }
private var metaFont: Font { .system(size: 22, weight: .medium) }
#else
private var heroHeight: CGFloat { 320 }
private var heroPadH: CGFloat { 28 }
private var heroPadV: CGFloat { 28 }
private var heroContentSpacing: CGFloat { 12 }
private var pitcherSize: CGFloat { 30 }
private var matchupFont: Font { .system(size: 32, weight: .black, design: .rounded) }
private var subtitleFont: Font { .system(size: 16, weight: .semibold) }
private var liveScoreFont: Font { .system(size: 48, weight: .black, design: .rounded) }
private var inningFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
private var badgeFont: Font { .system(size: 14, weight: .black, design: .rounded) }
private var ctaFont: Font { .system(size: 16, weight: .bold) }
private var ctaPadH: CGFloat { 22 }
private var ctaPadV: CGFloat { 10 }
private var pitcherFont: Font { .system(size: 15, weight: .semibold) }
private var recordFont: Font { .system(size: 14, weight: .bold, design: .monospaced) }
private var metaFont: Font { .system(size: 14, weight: .medium) }
#endif
}