The app was crashing from memory pressure on tvOS. Three causes fixed: 1. Feed was rendering all 418 highlights at once — capped to 50 items. 2. FeaturedGameCard had 3 blur effects (radius 80-120) on large circles for team color glow — replaced with a single LinearGradient. Same visual effect, fraction of the GPU memory. 3. BroadcastBackground had 3 blurred circles (radius 120-140, 680-900px) rendering on every screen — replaced with RadialGradients which are composited by the GPU natively without offscreen render passes. Also fixed iOS build: replaced tvOS-only font refs (tvSectionTitle, tvBody) with cross-platform equivalents in DashboardView fallback state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
329 lines
12 KiB
Swift
329 lines
12 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: { broadcast in
|
|
viewModel.activeStreams.contains(where: { $0.id == broadcast.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: cardSpacing) {
|
|
headerRow
|
|
matchupBlock
|
|
footerBlock
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .topLeading)
|
|
.padding(cardPad)
|
|
.background(cardBackground)
|
|
.overlay(cardBorder)
|
|
.clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous))
|
|
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
|
|
private var headerRow: some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
statusPill
|
|
Spacer(minLength: 8)
|
|
|
|
if inMultiView {
|
|
chip(title: "In Multi-View", tint: DS.Colors.positive)
|
|
}
|
|
|
|
if game.hasStreams {
|
|
chip(title: "\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", tint: DS.Colors.interactive)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var matchupBlock: some View {
|
|
VStack(spacing: 16) {
|
|
teamRow(team: game.awayTeam, isLeading: isWinning(away: true))
|
|
teamRow(team: game.homeTeam, isLeading: isWinning(away: false))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var footerBlock: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
switch game.status {
|
|
case .live:
|
|
liveFooter
|
|
case .final_:
|
|
finalFooter
|
|
case .scheduled:
|
|
scheduledFooter
|
|
case .unknown:
|
|
unknownFooter
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
private func teamRow(team: TeamInfo, isLeading: Bool) -> some View {
|
|
HStack(spacing: 14) {
|
|
TeamLogoView(team: team, size: logoSize)
|
|
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 10) {
|
|
Text(team.code)
|
|
.font(codeFont)
|
|
.foregroundStyle(.white)
|
|
|
|
if let record = team.record {
|
|
Text(record)
|
|
.font(metaFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
}
|
|
|
|
Text(team.displayName)
|
|
.font(nameFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
.lineLimit(1)
|
|
|
|
if let summary = team.standingSummary {
|
|
Text(summary)
|
|
.font(metaFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
Text(team.score.map(String.init) ?? "—")
|
|
.font(scoreFont)
|
|
.foregroundStyle(isLeading ? .white : DS.Colors.textSecondary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var liveFooter: some View {
|
|
if let linescore = game.linescore, !game.status.isScheduled {
|
|
HStack(alignment: .bottom, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(game.currentInningDisplay ?? "Live")
|
|
.font(footerTitleFont)
|
|
.foregroundStyle(DS.Colors.live)
|
|
|
|
if let awayRuns = linescore.teams?.away?.runs,
|
|
let homeRuns = linescore.teams?.home?.runs,
|
|
let awayHits = linescore.teams?.away?.hits,
|
|
let homeHits = linescore.teams?.home?.hits {
|
|
HStack(spacing: 10) {
|
|
footerMetric(label: game.awayTeam.code, value: "\(awayRuns)R \(awayHits)H")
|
|
footerMetric(label: game.homeTeam.code, value: "\(homeRuns)R \(homeHits)H")
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 12)
|
|
|
|
DiamondView(
|
|
balls: linescore.balls ?? 0,
|
|
strikes: linescore.strikes ?? 0,
|
|
outs: linescore.outs ?? 0
|
|
)
|
|
}
|
|
|
|
MiniLinescoreView(
|
|
linescore: linescore,
|
|
awayCode: game.awayTeam.code,
|
|
homeCode: game.homeTeam.code
|
|
)
|
|
} else {
|
|
Text("Live update available")
|
|
.font(footerBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
}
|
|
|
|
private var finalFooter: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Final")
|
|
.font(footerTitleFont)
|
|
.foregroundStyle(DS.Colors.positive)
|
|
|
|
Text(game.scoreDisplay ?? "Game complete")
|
|
.font(footerValueFont)
|
|
.foregroundStyle(.white)
|
|
|
|
if let venue = game.venue {
|
|
Text(venue)
|
|
.font(footerBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var scheduledFooter: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(game.startTime ?? game.status.label)
|
|
.font(footerTitleFont)
|
|
.foregroundStyle(DS.Colors.warning)
|
|
|
|
Text(game.pitchers ?? "Probable pitchers pending")
|
|
.font(footerBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
.lineLimit(2)
|
|
|
|
if let venue = game.venue {
|
|
Text(venue)
|
|
.font(metaFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var unknownFooter: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Awaiting update")
|
|
.font(footerTitleFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
|
|
if let venue = game.venue {
|
|
Text(venue)
|
|
.font(footerBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func footerMetric(label: String, value: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label)
|
|
.font(metaFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
Text(value)
|
|
.font(footerValueFont)
|
|
.foregroundStyle(.white)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
|
|
private func chip(title: String, tint: Color) -> some View {
|
|
Text(title)
|
|
.font(chipFont)
|
|
.foregroundStyle(tint)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(
|
|
Capsule()
|
|
.fill(tint.opacity(0.12))
|
|
.overlay {
|
|
Capsule()
|
|
.strokeBorder(tint.opacity(0.22), lineWidth: 1)
|
|
}
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusPill: some View {
|
|
switch game.status {
|
|
case .live(let inning):
|
|
chip(title: inning?.uppercased() ?? "LIVE", tint: DS.Colors.live)
|
|
case .scheduled(let time):
|
|
chip(title: time.uppercased(), tint: DS.Colors.warning)
|
|
case .final_:
|
|
chip(title: "FINAL", tint: DS.Colors.positive)
|
|
case .unknown:
|
|
chip(title: "PENDING", tint: DS.Colors.textTertiary)
|
|
}
|
|
}
|
|
|
|
private var cardBackground: some View {
|
|
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
DS.Colors.panelFill,
|
|
DS.Colors.panelFillMuted,
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.overlay(alignment: .top) {
|
|
Rectangle()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [awayColor, homeColor],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
.frame(height: 5)
|
|
.clipShape(
|
|
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
|
|
)
|
|
}
|
|
}
|
|
|
|
private var cardBorder: some View {
|
|
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
|
|
.strokeBorder(borderColor, lineWidth: borderWidth)
|
|
}
|
|
|
|
private func isWinning(away: Bool) -> Bool {
|
|
guard let awayScore = game.awayTeam.score, let homeScore = game.homeTeam.score else {
|
|
return false
|
|
}
|
|
return away ? awayScore > homeScore : homeScore > awayScore
|
|
}
|
|
|
|
private var borderColor: Color {
|
|
if inMultiView { return DS.Colors.positive.opacity(0.46) }
|
|
if game.isLive { return DS.Colors.live.opacity(0.34) }
|
|
return DS.Colors.panelStroke
|
|
}
|
|
|
|
private var borderWidth: CGFloat {
|
|
inMultiView || game.isLive ? 1.6 : 1
|
|
}
|
|
|
|
#if os(tvOS)
|
|
private var cardHeight: CGFloat { 270 }
|
|
private var cardRadius: CGFloat { 28 }
|
|
private var cardPad: CGFloat { 24 }
|
|
private var cardSpacing: CGFloat { 18 }
|
|
private var logoSize: CGFloat { 46 }
|
|
private var codeFont: Font { .system(size: 24, weight: .black, design: .rounded) }
|
|
private var nameFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
|
|
private var metaFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
|
|
private var scoreFont: Font { .system(size: 38, weight: .black, design: .rounded).monospacedDigit() }
|
|
private var chipFont: Font { .system(size: 13, weight: .black, design: .rounded) }
|
|
private var footerTitleFont: Font { .system(size: 18, weight: .black, design: .rounded) }
|
|
private var footerValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
|
private var footerBodyFont: Font { .system(size: 16, weight: .semibold) }
|
|
#else
|
|
private var cardHeight: CGFloat { 200 }
|
|
private var cardRadius: CGFloat { 20 }
|
|
private var cardPad: CGFloat { 18 }
|
|
private var cardSpacing: CGFloat { 14 }
|
|
private var logoSize: CGFloat { 34 }
|
|
private var codeFont: Font { .system(size: 18, weight: .black, design: .rounded) }
|
|
private var nameFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
|
|
private var metaFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
|
|
private var scoreFont: Font { .system(size: 28, weight: .black, design: .rounded).monospacedDigit() }
|
|
private var chipFont: Font { .system(size: 10, weight: .black, design: .rounded) }
|
|
private var footerTitleFont: Font { .system(size: 13, weight: .black, design: .rounded) }
|
|
private var footerValueFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
|
|
private var footerBodyFont: Font { .system(size: 12, weight: .semibold) }
|
|
#endif
|
|
}
|