303 lines
10 KiB
Swift
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
|
|
)
|
|
}
|
|
.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)
|
|
}
|
|
}
|