Files
MLBApp/mlbTVOS/Views/FeaturedGameCard.swift
2026-03-26 15:37:31 -05:00

585 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 hasLinescore: Bool {
!game.status.isScheduled && (game.linescore?.hasData ?? false)
}
private var awayPitcherName: String? {
guard let pitchers = game.pitchers else { return nil }
let parts = pitchers.components(separatedBy: " vs ")
return parts.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) {
HStack(alignment: .top, spacing: 28) {
matchupColumn
.frame(maxWidth: .infinity, alignment: .leading)
sidePanel
.frame(width: 760, alignment: .leading)
}
.padding(.horizontal, 34)
.padding(.top, 30)
.padding(.bottom, 24)
footerBar
.padding(.horizontal, 34)
.padding(.vertical, 16)
.background(.white.opacity(0.04))
}
.background(cardBackground)
.overlay(alignment: .top) {
HStack(spacing: 0) {
Rectangle()
.fill(awayColor.opacity(0.92))
Rectangle()
.fill(homeColor.opacity(0.92))
}
.frame(height: 5)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
}
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.strokeBorder(borderColor, lineWidth: borderWidth)
)
.shadow(color: shadowColor, radius: 24, y: 10)
}
.buttonStyle(.card)
}
@ViewBuilder
private var matchupColumn: some View {
VStack(alignment: .leading, spacing: 18) {
headerBar
featuredTeamRow(
team: game.awayTeam,
color: awayColor,
pitcherURL: game.awayPitcherHeadshotURL,
pitcherName: awayPitcherName
)
scorePanel
featuredTeamRow(
team: game.homeTeam,
color: homeColor,
pitcherURL: game.homePitcherHeadshotURL,
pitcherName: homePitcherName
)
}
}
@ViewBuilder
private var headerBar: some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text((game.gameType ?? "Featured Game").uppercased())
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.6))
.kerning(1.8)
if let venue = game.venue {
Label(venue, systemImage: "mappin.and.ellipse")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
}
}
Spacer()
HStack(spacing: 10) {
if game.hasStreams {
headerChip(title: "WATCH", color: .blue)
} else if game.isBlackedOut {
headerChip(title: "BLACKOUT", color: .red)
}
statusChip
}
}
}
@ViewBuilder
private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View {
HStack(spacing: 18) {
TeamLogoView(team: team, size: 82)
.frame(width: 88, height: 88)
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(team.code)
.font(.system(size: 34, weight: .black, design: .rounded))
.foregroundStyle(.white)
if let record = team.record {
Text(record)
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.72))
.padding(.horizontal, 11)
.padding(.vertical, 6)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
}
Text(team.displayName)
.font(.system(size: 26, weight: .bold))
.foregroundStyle(.white.opacity(0.96))
.lineLimit(1)
.minimumScaleFactor(0.82)
if let standing = team.standingSummary {
Text(standing)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
.lineLimit(1)
}
}
Spacer(minLength: 14)
if pitcherURL != nil || pitcherName != nil {
HStack(spacing: 12) {
if pitcherURL != nil {
PitcherHeadshotView(
url: pitcherURL,
teamCode: team.code,
name: nil,
size: 46
)
}
VStack(alignment: .trailing, spacing: 4) {
Text("Probable")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.42))
.textCase(.uppercase)
Text(pitcherName ?? "TBD")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.86))
.lineLimit(1)
}
}
}
}
.padding(.horizontal, 22)
.padding(.vertical, 20)
.background {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(teamPanelBackground(color: color))
}
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var scorePanel: some View {
VStack(spacing: 10) {
if let summary = scoreSummaryText {
Text(summary)
.font(.system(size: 74, weight: .black, design: .rounded))
.foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText())
} else {
Text(game.status.label)
.font(.system(size: 54, weight: .black, design: .rounded))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
}
Text(stateHeadline)
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(stateHeadlineColor)
if let detail = stateDetailText {
Text(detail)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
.lineLimit(2)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 26)
.background {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.white.opacity(0.06))
}
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var sidePanel: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(spacing: 14) {
detailTile(
title: "Venue",
value: game.venue ?? "TBD",
icon: "mappin.and.ellipse",
accent: nil
)
detailTile(
title: "Pitching",
value: game.pitchers ?? "Pitchers TBD",
icon: "baseball",
accent: nil
)
detailTile(
title: "Coverage",
value: coverageSummary,
icon: coverageIconName,
accent: coverageAccent
)
}
if hasLinescore,
let linescore = game.linescore {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Linescore")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
Spacer()
Text(linescoreLabel)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.56))
}
LinescoreView(
linescore: linescore,
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code
)
}
.padding(20)
.background(panelBackground)
} else {
fallbackInfoPanel
}
}
}
@ViewBuilder
private func detailTile(title: String, value: String, icon: String, accent: Color?) -> some View {
VStack(alignment: .leading, spacing: 10) {
Label(title, systemImage: icon)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.52))
Text(value)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(accent ?? .white)
.lineLimit(2)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, minHeight: 102, alignment: .topLeading)
.padding(18)
.background(panelBackground)
}
@ViewBuilder
private var fallbackInfoPanel: some View {
VStack(alignment: .leading, spacing: 12) {
Text(hasLinescore ? "Game Flow" : "Matchup Notes")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
Text(fallbackSummary)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.white.opacity(0.78))
.lineLimit(3)
if let venue = game.venue {
Text(venue)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.52))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(22)
.background(panelBackground)
}
@ViewBuilder
private var footerBar: some View {
HStack(spacing: 14) {
Label(footerSummary, systemImage: footerIconName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.72))
.lineLimit(1)
Spacer()
HStack(spacing: 8) {
Image(systemName: game.hasStreams ? "play.fill" : "rectangle.and.text.magnifyingglass")
.font(.system(size: 13, weight: .bold))
Text(game.hasStreams ? "Watch Game" : "Open Matchup")
.font(.system(size: 15, weight: .bold))
}
.foregroundStyle(game.hasStreams ? .blue : .white.opacity(0.82))
}
}
@ViewBuilder
private var statusChip: some View {
switch game.status {
case .live(let inning):
HStack(spacing: 8) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
}
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.red.opacity(0.18))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.white.opacity(0.1))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.96))
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
@ViewBuilder
private func headerChip(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private var scoreSummaryText: String? {
guard let away = game.awayTeam.score, let home = game.homeTeam.score else { return nil }
return "\(away) - \(home)"
}
private var stateHeadline: String {
switch game.status {
case .scheduled:
return "First Pitch"
case .live(let inning):
return inning ?? "Live"
case .final_:
return "Final"
case .unknown:
return "Game Day"
}
}
private var stateHeadlineColor: Color {
switch game.status {
case .live:
return .red.opacity(0.92)
case .scheduled:
return .blue.opacity(0.92)
case .final_:
return .white.opacity(0.82)
case .unknown:
return .white.opacity(0.72)
}
}
private var stateDetailText: String? {
if game.status.isScheduled {
return game.pitchers ?? game.venue
}
return game.venue
}
private var coverageSummary: String {
if game.isBlackedOut {
return "Unavailable in your area"
}
if !game.broadcasts.isEmpty {
let names = game.broadcasts.map(\.displayLabel)
return names.joined(separator: " / ")
}
if game.status.isScheduled {
return "Feeds closer to game time"
}
return "No feeds listed"
}
private var coverageIconName: String {
if game.isBlackedOut { return "eye.slash.fill" }
if !game.broadcasts.isEmpty { return "tv.fill" }
return "dot.radiowaves.left.and.right"
}
private var coverageAccent: Color {
if game.isBlackedOut { return .red }
if !game.broadcasts.isEmpty { return .blue }
return .white
}
private var fallbackSummary: String {
if let pitchers = game.pitchers, !pitchers.isEmpty {
return pitchers
}
if game.isBlackedOut {
return "This game is blacked out in your area."
}
if !game.broadcasts.isEmpty {
return game.broadcasts.map(\.displayLabel).joined(separator: " / ")
}
return "Select the matchup to view streams and full game details."
}
private var footerSummary: String {
if game.isBlackedOut {
return "Blackout restrictions apply"
}
if !game.broadcasts.isEmpty {
let feeds = game.broadcasts.map(\.teamCode).joined(separator: " / ")
return "Coverage: \(feeds)"
}
return game.venue ?? "Game details"
}
private var footerIconName: String {
if game.isBlackedOut { return "eye.slash.fill" }
if !game.broadcasts.isEmpty { return "tv.fill" }
return "sportscourt.fill"
}
private var linescoreLabel: String {
if let inning = game.currentInningDisplay, !inning.isEmpty {
return inning.uppercased()
}
if game.isFinal {
return "FINAL"
}
return "GAME"
}
private func teamPanelBackground(color: Color) -> some ShapeStyle {
LinearGradient(
colors: [
color.opacity(0.22),
.white.opacity(0.05)
],
startPoint: .leading,
endPoint: .trailing
)
}
@ViewBuilder
private var panelBackground: some View {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.black.opacity(0.22))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var cardBackground: some View {
ZStack {
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(Color(red: 0.05, green: 0.07, blue: 0.11))
LinearGradient(
colors: [
awayColor.opacity(0.2),
Color(red: 0.05, green: 0.07, blue: 0.11),
homeColor.opacity(0.22)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(awayColor.opacity(0.18))
.frame(width: 320, height: 320)
.blur(radius: 64)
.offset(x: -280, y: -70)
Circle()
.fill(homeColor.opacity(0.18))
.frame(width: 360, height: 360)
.blur(radius: 72)
.offset(x: 320, y: 40)
Rectangle()
.fill(.white.opacity(0.03))
.frame(width: 1)
.padding(.vertical, 28)
.offset(x: 86)
}
}
private var borderColor: Color {
if game.isLive {
return .red.opacity(0.32)
}
if game.hasStreams {
return .blue.opacity(0.24)
}
return .white.opacity(0.08)
}
private var borderWidth: CGFloat {
game.isLive || game.hasStreams ? 2 : 1
}
private var shadowColor: Color {
if game.isLive {
return .red.opacity(0.18)
}
return .black.opacity(0.26)
}
}