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>
521 lines
19 KiB
Swift
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
|
|
}
|