Cinematic UI overhaul: focus states, full-bleed hero, score bug cards, grid Intel
Phase 1 - Focus & Typography: New TVFocusButtonStyle with 1.04x scale +
white glow border on focus, 0.97x press. Enforced 22px minimum text on
tvOS across DesignSystem (tvCaption 22px, tvBody 24px, tvDataValue 24px).
DataLabelStyle uses tvOS caption with reduced kerning.
Phase 2 - Today Tab: FeaturedGameCard redesigned as full-bleed hero with
away team left, home team right, 96pt score centered, DiamondView for
live count/outs. Removed side panel, replaced with single subtitle row.
GameCardView redesigned as horizontal score-bug style (~120px tall vs
320px) with team color accent bar, stacked logos, centered score, inline
mini linescore. Both show record + streak on every card.
Phase 3 - Intel Tab: Side-by-side layout on tvOS with standings
horizontal scroll on left (60%) and leaders vertical column on right
(40%). Both visible without scrolling past each other. iOS keeps the
stacked layout.
Phase 4 - Feed: Cards now horizontal with thumbnail left (300px tvOS),
info right. Added timestamp ("2h ago") to every card. All text meets
22px minimum on tvOS. Condensed game badge uses larger font.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,298 +16,183 @@ struct GameCardView: View {
|
||||
|
||||
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))
|
||||
HStack(spacing: 0) {
|
||||
// Team color accent bar
|
||||
HStack(spacing: 0) {
|
||||
Rectangle().fill(awayColor)
|
||||
Rectangle().fill(homeColor)
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
.frame(width: 4)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||
.padding(.vertical, 10)
|
||||
|
||||
// Mini linescore for live/final games
|
||||
HStack(spacing: teamBlockSpacing) {
|
||||
// Away team
|
||||
teamBlock(team: game.awayTeam, isWinning: isWinning(away: true))
|
||||
|
||||
// Status / Score center
|
||||
VStack(spacing: 4) {
|
||||
if !game.status.isScheduled, let away = game.awayTeam.score, let home = game.homeTeam.score {
|
||||
Text("\(away) - \(home)")
|
||||
.font(scoreFont)
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
statusPill
|
||||
}
|
||||
.frame(width: scoreCenterWidth)
|
||||
|
||||
// Home team
|
||||
teamBlock(team: game.homeTeam, isWinning: isWinning(away: false), trailing: true)
|
||||
}
|
||||
.padding(.horizontal, blockPadH)
|
||||
|
||||
// Mini linescore (if available)
|
||||
if let linescore = game.linescore, !game.status.isScheduled {
|
||||
Divider()
|
||||
.frame(height: dividerHeight)
|
||||
.background(.white.opacity(0.08))
|
||||
|
||||
MiniLinescoreView(
|
||||
linescore: linescore,
|
||||
awayCode: game.awayTeam.code,
|
||||
homeCode: game.homeTeam.code
|
||||
)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal, miniLSPad)
|
||||
}
|
||||
|
||||
Spacer(minLength: 10)
|
||||
|
||||
footer
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 320, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .leading)
|
||||
.padding(.vertical, cardPadV)
|
||||
.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))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cardRadius, 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
|
||||
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
|
||||
.strokeBorder(borderColor, lineWidth: borderWidth)
|
||||
)
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
// MARK: - Team Block
|
||||
|
||||
@ViewBuilder
|
||||
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
TeamLogoView(team: team, size: 46)
|
||||
.frame(width: 50, height: 50)
|
||||
private func teamBlock(team: TeamInfo, isWinning: Bool, trailing: Bool = false) -> some View {
|
||||
HStack(spacing: teamInnerSpacing) {
|
||||
if trailing { Spacer(minLength: 0) }
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 10) {
|
||||
Text(team.code)
|
||||
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
TeamLogoView(team: team, size: logoSize)
|
||||
|
||||
if let record = team.record {
|
||||
VStack(alignment: trailing ? .trailing : .leading, spacing: 2) {
|
||||
Text(team.code)
|
||||
.font(codeFont)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let record = team.record {
|
||||
HStack(spacing: 4) {
|
||||
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())
|
||||
.font(recordFont)
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
|
||||
if let streak = team.streak {
|
||||
Text(streak)
|
||||
.font(recordFont)
|
||||
.foregroundStyle(streak.hasPrefix("W") ? DS.Colors.positive : DS.Colors.live)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
if !trailing { Spacer(minLength: 0) }
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
// MARK: - Status
|
||||
|
||||
@ViewBuilder
|
||||
private var compactStatus: some View {
|
||||
private var statusPill: some View {
|
||||
switch game.status {
|
||||
case .live(let inning):
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(.red)
|
||||
.frame(width: 8, height: 8)
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(DS.Colors.live).frame(width: 6, height: 6)
|
||||
Text(inning ?? "LIVE")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.font(statusFont)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red.opacity(0.18))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(DS.Colors.live.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())
|
||||
.font(statusFont)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
|
||||
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())
|
||||
.font(statusFont)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
|
||||
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)
|
||||
// MARK: - Helpers
|
||||
|
||||
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 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 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 cardBackground: some ShapeStyle {
|
||||
Color(red: 0.06, green: 0.07, blue: 0.10)
|
||||
}
|
||||
|
||||
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"
|
||||
private var borderColor: Color {
|
||||
if inMultiView { return .green.opacity(0.35) }
|
||||
if game.isLive { return DS.Colors.live.opacity(0.3) }
|
||||
return .white.opacity(0.06)
|
||||
}
|
||||
|
||||
@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 var borderWidth: CGFloat {
|
||||
inMultiView || game.isLive ? 2 : 1
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
// MARK: - Platform Sizing
|
||||
|
||||
@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)
|
||||
}
|
||||
#if os(tvOS)
|
||||
private var cardHeight: CGFloat { 120 }
|
||||
private var cardPadV: CGFloat { 8 }
|
||||
private var cardRadius: CGFloat { 20 }
|
||||
private var logoSize: CGFloat { 40 }
|
||||
private var teamBlockSpacing: CGFloat { 12 }
|
||||
private var teamInnerSpacing: CGFloat { 12 }
|
||||
private var blockPadH: CGFloat { 18 }
|
||||
private var scoreCenterWidth: CGFloat { 140 }
|
||||
private var dividerHeight: CGFloat { 60 }
|
||||
private var miniLSPad: CGFloat { 16 }
|
||||
private var scoreFont: Font { .system(size: 30, weight: .black, design: .rounded) }
|
||||
private var codeFont: Font { .system(size: 24, weight: .black, design: .rounded) }
|
||||
private var recordFont: Font { .system(size: 18, weight: .bold, design: .monospaced) }
|
||||
private var statusFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
||||
#else
|
||||
private var cardHeight: CGFloat { 90 }
|
||||
private var cardPadV: CGFloat { 6 }
|
||||
private var cardRadius: CGFloat { 16 }
|
||||
private var logoSize: CGFloat { 30 }
|
||||
private var teamBlockSpacing: CGFloat { 8 }
|
||||
private var teamInnerSpacing: CGFloat { 8 }
|
||||
private var blockPadH: CGFloat { 12 }
|
||||
private var scoreCenterWidth: CGFloat { 100 }
|
||||
private var dividerHeight: CGFloat { 44 }
|
||||
private var miniLSPad: CGFloat { 10 }
|
||||
private var scoreFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
||||
private var codeFont: Font { .system(size: 17, weight: .black, design: .rounded) }
|
||||
private var recordFont: Font { .system(size: 12, weight: .bold, design: .monospaced) }
|
||||
private var statusFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
|
||||
#endif
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user