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>
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? {
|
|
guard let pitchers = game.pitchers else { return nil }
|
|
return pitchers.components(separatedBy: " vs ").first
|
|
}
|
|
|
|
private var homePitcherName: String? {
|
|
guard let pitchers = game.pitchers else { return nil }
|
|
let parts = pitchers.components(separatedBy: " vs ")
|
|
return parts.count > 1 ? parts.last : nil
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: onSelect) {
|
|
VStack(spacing: 0) {
|
|
// Full-bleed matchup area
|
|
ZStack {
|
|
heroBackground
|
|
|
|
VStack(spacing: heroSpacing) {
|
|
// Status + game type
|
|
HStack {
|
|
Text((game.gameType ?? "Featured").uppercased())
|
|
.font(labelFont)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
.kerning(1.5)
|
|
|
|
Spacer()
|
|
|
|
statusChip
|
|
}
|
|
|
|
// Main matchup: Away — Score — Home
|
|
HStack(spacing: 0) {
|
|
teamSide(
|
|
team: game.awayTeam,
|
|
color: awayColor,
|
|
pitcherURL: game.awayPitcherHeadshotURL,
|
|
pitcherName: awayPitcherName,
|
|
alignment: .leading
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
scoreCenter
|
|
.frame(width: scoreCenterWidth)
|
|
|
|
teamSide(
|
|
team: game.homeTeam,
|
|
color: homeColor,
|
|
pitcherURL: game.homePitcherHeadshotURL,
|
|
pitcherName: homePitcherName,
|
|
alignment: .trailing
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
// Subtitle line: venue + coverage
|
|
subtitleRow
|
|
}
|
|
.padding(.horizontal, heroPadH)
|
|
.padding(.vertical, heroPadV)
|
|
}
|
|
|
|
// Linescore bar (if game started)
|
|
if let linescore = game.linescore, !game.status.isScheduled {
|
|
LinescoreView(
|
|
linescore: linescore,
|
|
awayCode: game.awayTeam.code,
|
|
homeCode: game.homeTeam.code
|
|
)
|
|
.padding(.horizontal, heroPadH)
|
|
.padding(.vertical, 16)
|
|
.background(.black.opacity(0.3))
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: heroRadius, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: heroRadius, style: .continuous)
|
|
.strokeBorder(borderColor, lineWidth: game.isLive ? 2 : 1)
|
|
)
|
|
.shadow(color: game.isLive ? .red.opacity(0.2) : .black.opacity(0.3), radius: 24, y: 10)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
|
|
// MARK: - Team Side
|
|
|
|
@ViewBuilder
|
|
private func teamSide(
|
|
team: TeamInfo,
|
|
color: Color,
|
|
pitcherURL: URL?,
|
|
pitcherName: String?,
|
|
alignment: HorizontalAlignment
|
|
) -> some View {
|
|
VStack(alignment: alignment, spacing: teamInfoGap) {
|
|
TeamLogoView(team: team, size: logoSize)
|
|
|
|
VStack(alignment: alignment, spacing: 4) {
|
|
Text(team.code)
|
|
.font(teamCodeFont)
|
|
.foregroundStyle(.white)
|
|
|
|
Text(team.displayName)
|
|
.font(teamNameFont)
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.lineLimit(1)
|
|
}
|
|
|
|
if let record = team.record {
|
|
HStack(spacing: 6) {
|
|
Text(record)
|
|
.font(recordFont)
|
|
.foregroundStyle(.white.opacity(0.6))
|
|
|
|
if let streak = team.streak {
|
|
Text(streak)
|
|
.font(recordFont)
|
|
.foregroundStyle(streak.hasPrefix("W") ? DS.Colors.positive : DS.Colors.live)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let name = pitcherName {
|
|
VStack(alignment: alignment, spacing: 2) {
|
|
Text("PROBABLE")
|
|
.font(probableLabelFont)
|
|
.foregroundStyle(.white.opacity(0.35))
|
|
.kerning(1)
|
|
HStack(spacing: 6) {
|
|
if let url = pitcherURL {
|
|
PitcherHeadshotView(url: url, teamCode: team.code, name: nil, size: pitcherHeadshotSize)
|
|
}
|
|
Text(name)
|
|
.font(pitcherNameFont)
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Score Center
|
|
|
|
@ViewBuilder
|
|
private var scoreCenter: some View {
|
|
VStack(spacing: 8) {
|
|
if let away = game.awayTeam.score, let home = game.homeTeam.score {
|
|
Text("\(away) - \(home)")
|
|
.font(mainScoreFont)
|
|
.foregroundStyle(.white)
|
|
.monospacedDigit()
|
|
.contentTransition(.numericText())
|
|
} else {
|
|
Text(game.status.label)
|
|
.font(statusTimeFont)
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
if case .live = game.status {
|
|
if let inning = game.currentInningDisplay {
|
|
Text(inning)
|
|
.font(inningFont)
|
|
.foregroundStyle(DS.Colors.live)
|
|
}
|
|
|
|
// Count + outs for live games
|
|
if let linescore = game.linescore {
|
|
DiamondView(
|
|
balls: linescore.balls ?? 0,
|
|
strikes: linescore.strikes ?? 0,
|
|
outs: linescore.outs ?? 0
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Subtitle Row
|
|
|
|
@ViewBuilder
|
|
private var subtitleRow: some View {
|
|
HStack(spacing: 16) {
|
|
if let venue = game.venue {
|
|
Label(venue, systemImage: "mappin.and.ellipse")
|
|
.font(subtitleFont)
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if game.hasStreams {
|
|
Label("\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", systemImage: "tv.fill")
|
|
.font(subtitleFont)
|
|
.foregroundStyle(DS.Colors.interactive.opacity(0.9))
|
|
} else if game.isBlackedOut {
|
|
Label("Blacked Out", systemImage: "eye.slash.fill")
|
|
.font(subtitleFont)
|
|
.foregroundStyle(DS.Colors.live.opacity(0.8))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Status Chip
|
|
|
|
@ViewBuilder
|
|
private var statusChip: 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(chipFont)
|
|
.foregroundStyle(.white)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.background(DS.Colors.live.opacity(0.2))
|
|
.clipShape(Capsule())
|
|
|
|
case .scheduled(let time):
|
|
Text(time)
|
|
.font(chipFont)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.background(.white.opacity(0.1))
|
|
.clipShape(Capsule())
|
|
|
|
case .final_:
|
|
Text("FINAL")
|
|
.font(chipFont)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.background(.white.opacity(0.1))
|
|
.clipShape(Capsule())
|
|
|
|
case .unknown:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
// MARK: - Background
|
|
|
|
@ViewBuilder
|
|
private var heroBackground: some View {
|
|
ZStack {
|
|
// Base
|
|
Color(red: 0.04, green: 0.05, blue: 0.08)
|
|
|
|
// Team color washes
|
|
HStack(spacing: 0) {
|
|
awayColor.opacity(0.25)
|
|
Color.clear
|
|
homeColor.opacity(0.25)
|
|
}
|
|
|
|
// Glow orbs
|
|
Circle()
|
|
.fill(awayColor.opacity(0.2))
|
|
.frame(width: 400, height: 400)
|
|
.blur(radius: 100)
|
|
.offset(x: -300, y: -50)
|
|
|
|
Circle()
|
|
.fill(homeColor.opacity(0.2))
|
|
.frame(width: 400, height: 400)
|
|
.blur(radius: 100)
|
|
.offset(x: 300, y: 50)
|
|
}
|
|
}
|
|
|
|
private var borderColor: Color {
|
|
if game.isLive { return DS.Colors.live.opacity(0.35) }
|
|
if game.hasStreams { return DS.Colors.interactive.opacity(0.2) }
|
|
return .white.opacity(0.06)
|
|
}
|
|
|
|
// MARK: - Platform Sizing
|
|
|
|
#if os(tvOS)
|
|
private var heroRadius: CGFloat { 28 }
|
|
private var heroPadH: CGFloat { 50 }
|
|
private var heroPadV: CGFloat { 40 }
|
|
private var heroSpacing: CGFloat { 24 }
|
|
private var logoSize: CGFloat { 120 }
|
|
private var scoreCenterWidth: CGFloat { 280 }
|
|
private var teamInfoGap: CGFloat { 12 }
|
|
private var pitcherHeadshotSize: CGFloat { 36 }
|
|
|
|
private var mainScoreFont: Font { .system(size: 96, weight: .black, design: .rounded) }
|
|
private var statusTimeFont: Font { .system(size: 48, weight: .black, design: .rounded) }
|
|
private var inningFont: Font { .system(size: 24, weight: .bold, design: .rounded) }
|
|
private var teamCodeFont: Font { .system(size: 38, weight: .black, design: .rounded) }
|
|
private var teamNameFont: Font { .system(size: 24, weight: .bold) }
|
|
private var recordFont: Font { .system(size: 22, weight: .bold, design: .monospaced) }
|
|
private var subtitleFont: Font { .system(size: 22, weight: .medium) }
|
|
private var chipFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
|
private var labelFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
|
|
private var probableLabelFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
|
private var pitcherNameFont: Font { .system(size: 22, weight: .semibold) }
|
|
#else
|
|
private var heroRadius: CGFloat { 22 }
|
|
private var heroPadH: CGFloat { 24 }
|
|
private var heroPadV: CGFloat { 24 }
|
|
private var heroSpacing: CGFloat { 16 }
|
|
private var logoSize: CGFloat { 64 }
|
|
private var scoreCenterWidth: CGFloat { 160 }
|
|
private var teamInfoGap: CGFloat { 8 }
|
|
private var pitcherHeadshotSize: CGFloat { 28 }
|
|
|
|
private var mainScoreFont: Font { .system(size: 56, weight: .black, design: .rounded) }
|
|
private var statusTimeFont: Font { .system(size: 32, weight: .black, design: .rounded) }
|
|
private var inningFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
|
|
private var teamCodeFont: Font { .system(size: 24, weight: .black, design: .rounded) }
|
|
private var teamNameFont: Font { .system(size: 16, weight: .bold) }
|
|
private var recordFont: Font { .system(size: 13, weight: .bold, design: .monospaced) }
|
|
private var subtitleFont: Font { .system(size: 14, weight: .medium) }
|
|
private var chipFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
|
private var labelFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
|
|
private var probableLabelFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
|
|
private var pitcherNameFont: Font { .system(size: 14, weight: .semibold) }
|
|
#endif
|
|
}
|