Title was clipping off the left edge ("ston Astros" instead of "Houston
Astros"). Root cause was too much content in a fixed-height card.
Fixed by: using minHeight instead of fixed height so the card expands
to fit content, simplified team rows to just logo + code + record +
score (removed full team name and standings summary since the title
already shows them), reduced title from 44pt to 36pt, added .clipped()
to hero image, added .fixedSize(horizontal: false, vertical: true).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
520 lines
19 KiB
Swift
520 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
|
|
|
|
// Team rows — simple, no side panel
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
scoreboardRow(team: game.awayTeam, isLeading: isWinning(away: true))
|
|
scoreboardRow(team: game.homeTeam, isLeading: isWinning(away: false))
|
|
}
|
|
|
|
// Live situation inline (if live)
|
|
if game.isLive, let linescore = game.linescore {
|
|
HStack(spacing: 20) {
|
|
if let inning = game.currentInningDisplay {
|
|
Text(inning)
|
|
.font(inningFont)
|
|
.foregroundStyle(DS.Colors.live)
|
|
}
|
|
DiamondView(
|
|
balls: linescore.balls ?? 0,
|
|
strikes: linescore.strikes ?? 0,
|
|
outs: linescore.outs ?? 0
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, heroPadH)
|
|
.padding(.vertical, heroPadV)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: heroHeight)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.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)
|
|
.clipped()
|
|
.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: 14) {
|
|
TeamLogoView(team: team, size: logoSize)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(team.code)
|
|
.font(codeFont)
|
|
.foregroundStyle(.white)
|
|
|
|
if let record = team.record {
|
|
Text(record)
|
|
.font(metadataFont)
|
|
.foregroundStyle(DS.Colors.onDarkTertiary)
|
|
}
|
|
}
|
|
|
|
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 { 460 }
|
|
private var heroRadius: CGFloat { 34 }
|
|
private var heroPadH: CGFloat { 44 }
|
|
private var heroPadV: CGFloat { 32 }
|
|
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: 36, 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 inningFont: Font { .system(size: 24, weight: .bold, design: .rounded) }
|
|
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 inningFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
|
|
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
|
|
}
|