Files
MLBApp/mlbTVOS/Views/GameCardView.swift
Trey t fda809fd2f Add iOS/iPad target with platform-adaptive UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:30:28 -05:00

303 lines
10 KiB
Swift

import SwiftUI
struct GameCardView: View {
let game: Game
let onSelect: () -> Void
@Environment(GamesViewModel.self) private var viewModel
private var inMultiView: Bool {
game.broadcasts.contains(where: { bc in
viewModel.activeStreams.contains(where: { $0.id == bc.id })
})
}
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 22)
.padding(.top, 18)
.padding(.bottom, 16)
VStack(spacing: 12) {
teamRow(team: game.awayTeam, isWinning: isWinning(away: true))
teamRow(team: game.homeTeam, isWinning: isWinning(away: false))
}
.padding(.horizontal, 22)
Spacer(minLength: 14)
footer
.padding(.horizontal, 22)
.padding(.vertical, 16)
}
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: 320, alignment: .topLeading)
.background(cardBackground)
.overlay(alignment: .top) {
HStack(spacing: 0) {
Rectangle()
.fill(awayColor.opacity(0.95))
Rectangle()
.fill(homeColor.opacity(0.95))
}
.frame(height: 4)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
}
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(
inMultiView ? .green.opacity(0.4) :
game.isLive ? .red.opacity(0.4) :
.white.opacity(0.08),
lineWidth: inMultiView || game.isLive ? 2 : 1
)
)
.shadow(
color: shadowColor,
radius: 18,
y: 8
)
}
.platformCardStyle()
}
@ViewBuilder
private var header: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 6) {
Text((game.gameType ?? "Matchup").uppercased())
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.58))
.kerning(1.2)
.lineLimit(1)
Text(subtitleText)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
}
Spacer(minLength: 12)
compactStatus
}
}
@ViewBuilder
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View {
HStack(spacing: 14) {
TeamLogoView(team: team, size: 46)
.frame(width: 50, height: 50)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 10) {
Text(team.code)
.font(.system(size: 28, weight: .black, design: .rounded))
.foregroundStyle(.white)
if let record = team.record {
Text(record)
.font(.system(size: 12, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.72))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.white.opacity(isWinning ? 0.12 : 0.07))
.clipShape(Capsule())
}
}
Text(team.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(isWinning ? 0.88 : 0.68))
.lineLimit(1)
.minimumScaleFactor(0.75)
}
Spacer(minLength: 12)
if !game.status.isScheduled, let score = team.score {
Text("\(score)")
.font(.system(size: 42, weight: .black, design: .rounded))
.foregroundStyle(isWinning ? .white : .white.opacity(0.72))
.contentTransition(.numericText())
}
}
.padding(.horizontal, 14)
.padding(.vertical, 13)
.background {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(rowBackground(for: team, isWinning: isWinning))
}
}
private func isWinning(away: Bool) -> Bool {
guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false }
return away ? a > h : h > a
}
private var subtitleText: String {
if game.status.isScheduled {
return game.pitchers ?? game.venue ?? "Upcoming"
}
if game.isBlackedOut {
return "Regional blackout"
}
if !game.broadcasts.isEmpty {
let count = game.broadcasts.count
return "\(count) feed\(count == 1 ? "" : "s") available"
}
return game.venue ?? "No feeds listed yet"
}
@ViewBuilder
private var compactStatus: some View {
switch game.status {
case .live(let inning):
HStack(spacing: 7) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.red.opacity(0.18))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.system(size: 13, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.92))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
@ViewBuilder
private var footer: some View {
HStack(spacing: 12) {
Label(footerText, systemImage: footerIconName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.66))
.lineLimit(1)
Spacer(minLength: 12)
if inMultiView {
footerBadge(title: "In Multi-View", color: .green)
} else if game.isBlackedOut {
footerBadge(title: "Blacked Out", color: .red)
} else if game.hasStreams {
footerBadge(title: "Watch", color: .blue)
}
}
.overlay(alignment: .top) {
Rectangle()
.fill(.white.opacity(0.08))
.frame(height: 1)
}
}
private var footerText: String {
if game.status.isScheduled {
return game.venue ?? (game.pitchers ?? "First pitch later today")
}
if game.isBlackedOut {
return "This game is unavailable in your area"
}
if let pitchers = game.pitchers, !pitchers.isEmpty {
return pitchers
}
if !game.broadcasts.isEmpty {
return game.broadcasts.map(\.teamCode).joined(separator: "")
}
return game.venue ?? "Tap for details"
}
private var footerIconName: String {
if game.isBlackedOut { return "eye.slash.fill" }
if game.hasStreams { return "tv.fill" }
if game.status.isScheduled { return "mappin.and.ellipse" }
return "sportscourt.fill"
}
@ViewBuilder
private func footerBadge(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private func rowBackground(for team: TeamInfo, isWinning: Bool) -> some ShapeStyle {
let color = TeamAssets.color(for: team.code)
return LinearGradient(
colors: [
color.opacity(isWinning ? 0.22 : 0.12),
.white.opacity(isWinning ? 0.07 : 0.03)
],
startPoint: .leading,
endPoint: .trailing
)
}
@ViewBuilder
private var cardBackground: some View {
ZStack {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(red: 0.08, green: 0.09, blue: 0.12))
LinearGradient(
colors: [
awayColor.opacity(0.18),
Color.clear,
homeColor.opacity(0.18)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(awayColor.opacity(0.18))
.frame(width: 180)
.blur(radius: 40)
.offset(x: -110, y: -90)
Circle()
.fill(homeColor.opacity(0.16))
.frame(width: 200)
.blur(radius: 44)
.offset(x: 140, y: 120)
}
}
private var shadowColor: Color {
if inMultiView { return .green.opacity(0.18) }
if game.isLive { return .red.opacity(0.22) }
return .black.opacity(0.22)
}
}