Initial commit
This commit is contained in:
302
mlbTVOS/Views/GameCardView.swift
Normal file
302
mlbTVOS/Views/GameCardView.swift
Normal file
@@ -0,0 +1,302 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user