593 lines
19 KiB
Swift
593 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) {
|
|
ViewThatFits {
|
|
HStack(alignment: .top, spacing: 28) {
|
|
matchupColumn
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
sidePanel
|
|
.frame(width: 760, alignment: .leading)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
matchupColumn
|
|
sidePanel
|
|
.frame(maxWidth: .infinity, 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)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
|
|
@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)
|
|
}
|
|
}
|