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:
@@ -40,14 +40,14 @@ enum DS {
|
|||||||
static let bodySmall = Font.system(size: 13, weight: .medium)
|
static let bodySmall = Font.system(size: 13, weight: .medium)
|
||||||
static let caption = Font.system(size: 11, weight: .bold, design: .rounded)
|
static let caption = Font.system(size: 11, weight: .bold, design: .rounded)
|
||||||
|
|
||||||
// tvOS scaled variants
|
// tvOS scaled variants — 22px minimum for readability at 10ft
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
static let tvSectionTitle = Font.system(size: 36, weight: .bold, design: .rounded)
|
static let tvSectionTitle = Font.system(size: 38, weight: .bold, design: .rounded)
|
||||||
static let tvCardTitle = Font.system(size: 26, weight: .bold, design: .rounded)
|
static let tvCardTitle = Font.system(size: 28, weight: .bold, design: .rounded)
|
||||||
static let tvScore = Font.system(size: 34, weight: .black, design: .rounded).monospacedDigit()
|
static let tvScore = Font.system(size: 36, weight: .black, design: .rounded).monospacedDigit()
|
||||||
static let tvDataValue = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit()
|
static let tvDataValue = Font.system(size: 24, weight: .bold, design: .rounded).monospacedDigit()
|
||||||
static let tvBody = Font.system(size: 20, weight: .medium)
|
static let tvBody = Font.system(size: 24, weight: .medium)
|
||||||
static let tvCaption = Font.system(size: 15, weight: .bold, design: .rounded)
|
static let tvCaption = Font.system(size: 22, weight: .bold, design: .rounded)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,10 +87,15 @@ enum DS {
|
|||||||
struct DataLabelStyle: ViewModifier {
|
struct DataLabelStyle: ViewModifier {
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
|
#if os(tvOS)
|
||||||
|
.font(DS.Fonts.tvCaption)
|
||||||
|
.kerning(1.0)
|
||||||
|
#else
|
||||||
.font(DS.Fonts.caption)
|
.font(DS.Fonts.caption)
|
||||||
|
.kerning(1.5)
|
||||||
|
#endif
|
||||||
.foregroundStyle(DS.Colors.textQuaternary)
|
.foregroundStyle(DS.Colors.textQuaternary)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.kerning(1.5)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - iOS Press Style
|
||||||
|
|
||||||
struct PlatformPressButtonStyle: ButtonStyle {
|
struct PlatformPressButtonStyle: ButtonStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
@@ -9,11 +11,38 @@ struct PlatformPressButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - tvOS Focus Style
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
struct TVFocusButtonStyle: ButtonStyle {
|
||||||
|
@Environment(\.isFocused) private var isFocused
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.97 : isFocused ? 1.04 : 1.0)
|
||||||
|
.opacity(configuration.isPressed ? 0.85 : 1.0)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(isFocused ? 0.3 : 0), lineWidth: 2)
|
||||||
|
)
|
||||||
|
.shadow(
|
||||||
|
color: isFocused ? .white.opacity(0.12) : .clear,
|
||||||
|
radius: isFocused ? 20 : 0,
|
||||||
|
y: isFocused ? 8 : 0
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: isFocused)
|
||||||
|
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Platform Extensions
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func platformCardStyle() -> some View {
|
func platformCardStyle() -> some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
self.buttonStyle(.card)
|
self.buttonStyle(TVFocusButtonStyle())
|
||||||
#else
|
#else
|
||||||
self.buttonStyle(PlatformPressButtonStyle())
|
self.buttonStyle(PlatformPressButtonStyle())
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -84,9 +84,9 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
private var shelfCardWidth: CGFloat {
|
private var shelfCardWidth: CGFloat {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
horizontalSizeClass == .compact ? 300 : 360
|
horizontalSizeClass == .compact ? 340 : 480
|
||||||
#else
|
#else
|
||||||
400
|
580
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,9 @@ struct FeaturedGameCard: View {
|
|||||||
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
|
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
|
||||||
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
|
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
|
||||||
|
|
||||||
private var hasLinescore: Bool {
|
|
||||||
!game.status.isScheduled && (game.linescore?.hasData ?? false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var awayPitcherName: String? {
|
private var awayPitcherName: String? {
|
||||||
guard let pitchers = game.pitchers else { return nil }
|
guard let pitchers = game.pitchers else { return nil }
|
||||||
let parts = pitchers.components(separatedBy: " vs ")
|
return pitchers.components(separatedBy: " vs ").first
|
||||||
return parts.first
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var homePitcherName: String? {
|
private var homePitcherName: String? {
|
||||||
@@ -26,370 +21,229 @@ struct FeaturedGameCard: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onSelect) {
|
Button(action: onSelect) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ViewThatFits {
|
// Full-bleed matchup area
|
||||||
HStack(alignment: .top, spacing: 28) {
|
ZStack {
|
||||||
matchupColumn
|
heroBackground
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
sidePanel
|
VStack(spacing: heroSpacing) {
|
||||||
.frame(width: 760, alignment: .leading)
|
// Status + game type
|
||||||
|
HStack {
|
||||||
|
Text((game.gameType ?? "Featured").uppercased())
|
||||||
|
.font(labelFont)
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
.kerning(1.5)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
statusChip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main matchup: Away — Score — Home
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
teamSide(
|
||||||
|
team: game.awayTeam,
|
||||||
|
color: awayColor,
|
||||||
|
pitcherURL: game.awayPitcherHeadshotURL,
|
||||||
|
pitcherName: awayPitcherName,
|
||||||
|
alignment: .leading
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
scoreCenter
|
||||||
|
.frame(width: scoreCenterWidth)
|
||||||
|
|
||||||
|
teamSide(
|
||||||
|
team: game.homeTeam,
|
||||||
|
color: homeColor,
|
||||||
|
pitcherURL: game.homePitcherHeadshotURL,
|
||||||
|
pitcherName: homePitcherName,
|
||||||
|
alignment: .trailing
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle line: venue + coverage
|
||||||
|
subtitleRow
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, heroPadH)
|
||||||
VStack(alignment: .leading, spacing: 24) {
|
.padding(.vertical, heroPadV)
|
||||||
matchupColumn
|
|
||||||
sidePanel
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 34)
|
|
||||||
.padding(.top, 30)
|
|
||||||
.padding(.bottom, 24)
|
|
||||||
|
|
||||||
footerBar
|
|
||||||
.padding(.horizontal, 34)
|
|
||||||
.padding(.vertical, 16)
|
|
||||||
.background(.white.opacity(0.04))
|
|
||||||
}
|
|
||||||
.background(cardBackground)
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
Rectangle()
|
|
||||||
.fill(awayColor.opacity(0.92))
|
|
||||||
Rectangle()
|
|
||||||
.fill(homeColor.opacity(0.92))
|
|
||||||
}
|
|
||||||
.frame(height: 5)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
|
||||||
.strokeBorder(borderColor, lineWidth: borderWidth)
|
|
||||||
)
|
|
||||||
.shadow(color: shadowColor, radius: 24, y: 10)
|
|
||||||
}
|
|
||||||
.platformCardStyle()
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var matchupColumn: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
|
||||||
headerBar
|
|
||||||
|
|
||||||
featuredTeamRow(
|
|
||||||
team: game.awayTeam,
|
|
||||||
color: awayColor,
|
|
||||||
pitcherURL: game.awayPitcherHeadshotURL,
|
|
||||||
pitcherName: awayPitcherName
|
|
||||||
)
|
|
||||||
|
|
||||||
scorePanel
|
|
||||||
|
|
||||||
featuredTeamRow(
|
|
||||||
team: game.homeTeam,
|
|
||||||
color: homeColor,
|
|
||||||
pitcherURL: game.homePitcherHeadshotURL,
|
|
||||||
pitcherName: homePitcherName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var headerBar: some View {
|
|
||||||
HStack(alignment: .top, spacing: 16) {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text((game.gameType ?? "Featured Game").uppercased())
|
|
||||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
|
||||||
.kerning(1.8)
|
|
||||||
|
|
||||||
if let venue = game.venue {
|
|
||||||
Label(venue, systemImage: "mappin.and.ellipse")
|
|
||||||
.font(.system(size: 14, weight: .medium))
|
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
if game.hasStreams {
|
|
||||||
headerChip(title: "WATCH", color: .blue)
|
|
||||||
} else if game.isBlackedOut {
|
|
||||||
headerChip(title: "BLACKOUT", color: .red)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
statusChip
|
// Linescore bar (if game started)
|
||||||
}
|
if let linescore = game.linescore, !game.status.isScheduled {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View {
|
|
||||||
HStack(spacing: 18) {
|
|
||||||
TeamLogoView(team: team, size: 82)
|
|
||||||
.frame(width: 88, height: 88)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Text(team.code)
|
|
||||||
.font(.system(size: 34, weight: .black, design: .rounded))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
|
|
||||||
if let record = team.record {
|
|
||||||
Text(record)
|
|
||||||
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
|
||||||
.foregroundStyle(.white.opacity(0.72))
|
|
||||||
.padding(.horizontal, 11)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(.white.opacity(0.08))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(team.displayName)
|
|
||||||
.font(.system(size: 26, weight: .bold))
|
|
||||||
.foregroundStyle(.white.opacity(0.96))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.82)
|
|
||||||
|
|
||||||
if let standing = team.standingSummary {
|
|
||||||
Text(standing)
|
|
||||||
.font(.system(size: 15, weight: .medium))
|
|
||||||
.foregroundStyle(.white.opacity(0.58))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 14)
|
|
||||||
|
|
||||||
if pitcherURL != nil || pitcherName != nil {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
if pitcherURL != nil {
|
|
||||||
PitcherHeadshotView(
|
|
||||||
url: pitcherURL,
|
|
||||||
teamCode: team.code,
|
|
||||||
name: nil,
|
|
||||||
size: 46
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
|
||||||
Text("Probable")
|
|
||||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(.white.opacity(0.42))
|
|
||||||
.textCase(.uppercase)
|
|
||||||
|
|
||||||
Text(pitcherName ?? "TBD")
|
|
||||||
.font(.system(size: 16, weight: .semibold))
|
|
||||||
.foregroundStyle(.white.opacity(0.86))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 22)
|
|
||||||
.padding(.vertical, 20)
|
|
||||||
.background {
|
|
||||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
||||||
.fill(teamPanelBackground(color: color))
|
|
||||||
}
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
||||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var scorePanel: some View {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
if let summary = scoreSummaryText {
|
|
||||||
Text(summary)
|
|
||||||
.font(.system(size: 74, weight: .black, design: .rounded))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.monospacedDigit()
|
|
||||||
.contentTransition(.numericText())
|
|
||||||
} else {
|
|
||||||
Text(game.status.label)
|
|
||||||
.font(.system(size: 54, weight: .black, design: .rounded))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(stateHeadline)
|
|
||||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
|
||||||
.foregroundStyle(stateHeadlineColor)
|
|
||||||
|
|
||||||
if let detail = stateDetailText {
|
|
||||||
Text(detail)
|
|
||||||
.font(.system(size: 15, weight: .medium))
|
|
||||||
.foregroundStyle(.white.opacity(0.6))
|
|
||||||
.lineLimit(2)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.vertical, 26)
|
|
||||||
.background {
|
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
||||||
.fill(.white.opacity(0.06))
|
|
||||||
}
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
||||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var sidePanel: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
detailTile(
|
|
||||||
title: "Venue",
|
|
||||||
value: game.venue ?? "TBD",
|
|
||||||
icon: "mappin.and.ellipse",
|
|
||||||
accent: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
detailTile(
|
|
||||||
title: "Pitching",
|
|
||||||
value: game.pitchers ?? "Pitchers TBD",
|
|
||||||
icon: "baseball",
|
|
||||||
accent: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
detailTile(
|
|
||||||
title: "Coverage",
|
|
||||||
value: coverageSummary,
|
|
||||||
icon: coverageIconName,
|
|
||||||
accent: coverageAccent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasLinescore,
|
|
||||||
let linescore = game.linescore {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
HStack {
|
|
||||||
Text("Linescore")
|
|
||||||
.font(.system(size: 18, weight: .bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text(linescoreLabel)
|
|
||||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(.white.opacity(0.56))
|
|
||||||
}
|
|
||||||
|
|
||||||
LinescoreView(
|
LinescoreView(
|
||||||
linescore: linescore,
|
linescore: linescore,
|
||||||
awayCode: game.awayTeam.code,
|
awayCode: game.awayTeam.code,
|
||||||
homeCode: game.homeTeam.code
|
homeCode: game.homeTeam.code
|
||||||
)
|
)
|
||||||
|
.padding(.horizontal, heroPadH)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(.black.opacity(0.3))
|
||||||
}
|
}
|
||||||
.padding(20)
|
}
|
||||||
.background(panelBackground)
|
.clipShape(RoundedRectangle(cornerRadius: heroRadius, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: heroRadius, style: .continuous)
|
||||||
|
.strokeBorder(borderColor, lineWidth: game.isLive ? 2 : 1)
|
||||||
|
)
|
||||||
|
.shadow(color: game.isLive ? .red.opacity(0.2) : .black.opacity(0.3), radius: 24, y: 10)
|
||||||
|
}
|
||||||
|
.platformCardStyle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Team Side
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func teamSide(
|
||||||
|
team: TeamInfo,
|
||||||
|
color: Color,
|
||||||
|
pitcherURL: URL?,
|
||||||
|
pitcherName: String?,
|
||||||
|
alignment: HorizontalAlignment
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: alignment, spacing: teamInfoGap) {
|
||||||
|
TeamLogoView(team: team, size: logoSize)
|
||||||
|
|
||||||
|
VStack(alignment: alignment, spacing: 4) {
|
||||||
|
Text(team.code)
|
||||||
|
.font(teamCodeFont)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(team.displayName)
|
||||||
|
.font(teamNameFont)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let record = team.record {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(record)
|
||||||
|
.font(recordFont)
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
|
||||||
|
if let streak = team.streak {
|
||||||
|
Text(streak)
|
||||||
|
.font(recordFont)
|
||||||
|
.foregroundStyle(streak.hasPrefix("W") ? DS.Colors.positive : DS.Colors.live)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let name = pitcherName {
|
||||||
|
VStack(alignment: alignment, spacing: 2) {
|
||||||
|
Text("PROBABLE")
|
||||||
|
.font(probableLabelFont)
|
||||||
|
.foregroundStyle(.white.opacity(0.35))
|
||||||
|
.kerning(1)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let url = pitcherURL {
|
||||||
|
PitcherHeadshotView(url: url, teamCode: team.code, name: nil, size: pitcherHeadshotSize)
|
||||||
|
}
|
||||||
|
Text(name)
|
||||||
|
.font(pitcherNameFont)
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Score Center
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var scoreCenter: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if let away = game.awayTeam.score, let home = game.homeTeam.score {
|
||||||
|
Text("\(away) - \(home)")
|
||||||
|
.font(mainScoreFont)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.monospacedDigit()
|
||||||
|
.contentTransition(.numericText())
|
||||||
} else {
|
} else {
|
||||||
fallbackInfoPanel
|
Text(game.status.label)
|
||||||
|
.font(statusTimeFont)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .live = game.status {
|
||||||
|
if let inning = game.currentInningDisplay {
|
||||||
|
Text(inning)
|
||||||
|
.font(inningFont)
|
||||||
|
.foregroundStyle(DS.Colors.live)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count + outs for live games
|
||||||
|
if let linescore = game.linescore {
|
||||||
|
DiamondView(
|
||||||
|
balls: linescore.balls ?? 0,
|
||||||
|
strikes: linescore.strikes ?? 0,
|
||||||
|
outs: linescore.outs ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Subtitle Row
|
||||||
private func detailTile(title: String, value: String, icon: String, accent: Color?) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Label(title, systemImage: icon)
|
|
||||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(.white.opacity(0.52))
|
|
||||||
|
|
||||||
Text(value)
|
|
||||||
.font(.system(size: 17, weight: .semibold))
|
|
||||||
.foregroundStyle(accent ?? .white)
|
|
||||||
.lineLimit(2)
|
|
||||||
.minimumScaleFactor(0.82)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 102, alignment: .topLeading)
|
|
||||||
.padding(18)
|
|
||||||
.background(panelBackground)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var fallbackInfoPanel: some View {
|
private var subtitleRow: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
HStack(spacing: 16) {
|
||||||
Text(hasLinescore ? "Game Flow" : "Matchup Notes")
|
|
||||||
.font(.system(size: 18, weight: .bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
|
|
||||||
Text(fallbackSummary)
|
|
||||||
.font(.system(size: 18, weight: .medium))
|
|
||||||
.foregroundStyle(.white.opacity(0.78))
|
|
||||||
.lineLimit(3)
|
|
||||||
|
|
||||||
if let venue = game.venue {
|
if let venue = game.venue {
|
||||||
Text(venue)
|
Label(venue, systemImage: "mappin.and.ellipse")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(subtitleFont)
|
||||||
.foregroundStyle(.white.opacity(0.52))
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(22)
|
|
||||||
.background(panelBackground)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var footerBar: some View {
|
|
||||||
HStack(spacing: 14) {
|
|
||||||
Label(footerSummary, systemImage: footerIconName)
|
|
||||||
.font(.system(size: 15, weight: .semibold))
|
|
||||||
.foregroundStyle(.white.opacity(0.72))
|
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
if game.hasStreams {
|
||||||
Image(systemName: game.hasStreams ? "play.fill" : "rectangle.and.text.magnifyingglass")
|
Label("\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", systemImage: "tv.fill")
|
||||||
.font(.system(size: 13, weight: .bold))
|
.font(subtitleFont)
|
||||||
Text(game.hasStreams ? "Watch Game" : "Open Matchup")
|
.foregroundStyle(DS.Colors.interactive.opacity(0.9))
|
||||||
.font(.system(size: 15, weight: .bold))
|
} else if game.isBlackedOut {
|
||||||
|
Label("Blacked Out", systemImage: "eye.slash.fill")
|
||||||
|
.font(subtitleFont)
|
||||||
|
.foregroundStyle(DS.Colors.live.opacity(0.8))
|
||||||
}
|
}
|
||||||
.foregroundStyle(game.hasStreams ? .blue : .white.opacity(0.82))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Status Chip
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var statusChip: some View {
|
private var statusChip: some View {
|
||||||
switch game.status {
|
switch game.status {
|
||||||
case .live(let inning):
|
case .live(let inning):
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 6) {
|
||||||
Circle()
|
Circle().fill(DS.Colors.live).frame(width: 8, height: 8)
|
||||||
.fill(.red)
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
Text(inning ?? "LIVE")
|
Text(inning ?? "LIVE")
|
||||||
.font(.system(size: 15, weight: .black, design: .rounded))
|
.font(chipFont)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 15)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
.background(.red.opacity(0.18))
|
.background(DS.Colors.live.opacity(0.2))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
|
|
||||||
case .scheduled(let time):
|
case .scheduled(let time):
|
||||||
Text(time)
|
Text(time)
|
||||||
.font(.system(size: 15, weight: .black, design: .rounded))
|
.font(chipFont)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 15)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
.background(.white.opacity(0.1))
|
.background(.white.opacity(0.1))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
|
|
||||||
case .final_:
|
case .final_:
|
||||||
Text("FINAL")
|
Text("FINAL")
|
||||||
.font(.system(size: 15, weight: .black, design: .rounded))
|
.font(chipFont)
|
||||||
.foregroundStyle(.white.opacity(0.96))
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 15)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
.background(.white.opacity(0.12))
|
.background(.white.opacity(0.1))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
|
|
||||||
case .unknown:
|
case .unknown:
|
||||||
@@ -397,196 +251,85 @@ struct FeaturedGameCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Background
|
||||||
private func headerChip(title: String, color: Color) -> some View {
|
|
||||||
Text(title)
|
|
||||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(color)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 9)
|
|
||||||
.background(color.opacity(0.12))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
|
||||||
|
|
||||||
private var scoreSummaryText: String? {
|
|
||||||
guard let away = game.awayTeam.score, let home = game.homeTeam.score else { return nil }
|
|
||||||
return "\(away) - \(home)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var stateHeadline: String {
|
|
||||||
switch game.status {
|
|
||||||
case .scheduled:
|
|
||||||
return "First Pitch"
|
|
||||||
case .live(let inning):
|
|
||||||
return inning ?? "Live"
|
|
||||||
case .final_:
|
|
||||||
return "Final"
|
|
||||||
case .unknown:
|
|
||||||
return "Game Day"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var stateHeadlineColor: Color {
|
|
||||||
switch game.status {
|
|
||||||
case .live:
|
|
||||||
return .red.opacity(0.92)
|
|
||||||
case .scheduled:
|
|
||||||
return .blue.opacity(0.92)
|
|
||||||
case .final_:
|
|
||||||
return .white.opacity(0.82)
|
|
||||||
case .unknown:
|
|
||||||
return .white.opacity(0.72)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var stateDetailText: String? {
|
|
||||||
if game.status.isScheduled {
|
|
||||||
return game.pitchers ?? game.venue
|
|
||||||
}
|
|
||||||
return game.venue
|
|
||||||
}
|
|
||||||
|
|
||||||
private var coverageSummary: String {
|
|
||||||
if game.isBlackedOut {
|
|
||||||
return "Unavailable in your area"
|
|
||||||
}
|
|
||||||
if !game.broadcasts.isEmpty {
|
|
||||||
let names = game.broadcasts.map(\.displayLabel)
|
|
||||||
return names.joined(separator: " / ")
|
|
||||||
}
|
|
||||||
if game.status.isScheduled {
|
|
||||||
return "Feeds closer to game time"
|
|
||||||
}
|
|
||||||
return "No feeds listed"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var coverageIconName: String {
|
|
||||||
if game.isBlackedOut { return "eye.slash.fill" }
|
|
||||||
if !game.broadcasts.isEmpty { return "tv.fill" }
|
|
||||||
return "dot.radiowaves.left.and.right"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var coverageAccent: Color {
|
|
||||||
if game.isBlackedOut { return .red }
|
|
||||||
if !game.broadcasts.isEmpty { return .blue }
|
|
||||||
return .white
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fallbackSummary: String {
|
|
||||||
if let pitchers = game.pitchers, !pitchers.isEmpty {
|
|
||||||
return pitchers
|
|
||||||
}
|
|
||||||
if game.isBlackedOut {
|
|
||||||
return "This game is blacked out in your area."
|
|
||||||
}
|
|
||||||
if !game.broadcasts.isEmpty {
|
|
||||||
return game.broadcasts.map(\.displayLabel).joined(separator: " / ")
|
|
||||||
}
|
|
||||||
return "Select the matchup to view streams and full game details."
|
|
||||||
}
|
|
||||||
|
|
||||||
private var footerSummary: String {
|
|
||||||
if game.isBlackedOut {
|
|
||||||
return "Blackout restrictions apply"
|
|
||||||
}
|
|
||||||
if !game.broadcasts.isEmpty {
|
|
||||||
let feeds = game.broadcasts.map(\.teamCode).joined(separator: " / ")
|
|
||||||
return "Coverage: \(feeds)"
|
|
||||||
}
|
|
||||||
return game.venue ?? "Game details"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var footerIconName: String {
|
|
||||||
if game.isBlackedOut { return "eye.slash.fill" }
|
|
||||||
if !game.broadcasts.isEmpty { return "tv.fill" }
|
|
||||||
return "sportscourt.fill"
|
|
||||||
}
|
|
||||||
|
|
||||||
private var linescoreLabel: String {
|
|
||||||
if let inning = game.currentInningDisplay, !inning.isEmpty {
|
|
||||||
return inning.uppercased()
|
|
||||||
}
|
|
||||||
if game.isFinal {
|
|
||||||
return "FINAL"
|
|
||||||
}
|
|
||||||
return "GAME"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func teamPanelBackground(color: Color) -> some ShapeStyle {
|
|
||||||
LinearGradient(
|
|
||||||
colors: [
|
|
||||||
color.opacity(0.22),
|
|
||||||
.white.opacity(0.05)
|
|
||||||
],
|
|
||||||
startPoint: .leading,
|
|
||||||
endPoint: .trailing
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var panelBackground: some View {
|
private var heroBackground: some View {
|
||||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
||||||
.fill(.black.opacity(0.22))
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
||||||
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var cardBackground: some View {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
// Base
|
||||||
.fill(Color(red: 0.05, green: 0.07, blue: 0.11))
|
Color(red: 0.04, green: 0.05, blue: 0.08)
|
||||||
|
|
||||||
LinearGradient(
|
// Team color washes
|
||||||
colors: [
|
HStack(spacing: 0) {
|
||||||
awayColor.opacity(0.2),
|
awayColor.opacity(0.25)
|
||||||
Color(red: 0.05, green: 0.07, blue: 0.11),
|
Color.clear
|
||||||
homeColor.opacity(0.22)
|
homeColor.opacity(0.25)
|
||||||
],
|
}
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
// Glow orbs
|
||||||
)
|
Circle()
|
||||||
|
.fill(awayColor.opacity(0.2))
|
||||||
|
.frame(width: 400, height: 400)
|
||||||
|
.blur(radius: 100)
|
||||||
|
.offset(x: -300, y: -50)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.fill(awayColor.opacity(0.18))
|
.fill(homeColor.opacity(0.2))
|
||||||
.frame(width: 320, height: 320)
|
.frame(width: 400, height: 400)
|
||||||
.blur(radius: 64)
|
.blur(radius: 100)
|
||||||
.offset(x: -280, y: -70)
|
.offset(x: 300, y: 50)
|
||||||
|
|
||||||
Circle()
|
|
||||||
.fill(homeColor.opacity(0.18))
|
|
||||||
.frame(width: 360, height: 360)
|
|
||||||
.blur(radius: 72)
|
|
||||||
.offset(x: 320, y: 40)
|
|
||||||
|
|
||||||
Rectangle()
|
|
||||||
.fill(.white.opacity(0.03))
|
|
||||||
.frame(width: 1)
|
|
||||||
.padding(.vertical, 28)
|
|
||||||
.offset(x: 86)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var borderColor: Color {
|
private var borderColor: Color {
|
||||||
if game.isLive {
|
if game.isLive { return DS.Colors.live.opacity(0.35) }
|
||||||
return .red.opacity(0.32)
|
if game.hasStreams { return DS.Colors.interactive.opacity(0.2) }
|
||||||
}
|
return .white.opacity(0.06)
|
||||||
if game.hasStreams {
|
|
||||||
return .blue.opacity(0.24)
|
|
||||||
}
|
|
||||||
return .white.opacity(0.08)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var borderWidth: CGFloat {
|
// MARK: - Platform Sizing
|
||||||
game.isLive || game.hasStreams ? 2 : 1
|
|
||||||
}
|
|
||||||
|
|
||||||
private var shadowColor: Color {
|
#if os(tvOS)
|
||||||
if game.isLive {
|
private var heroRadius: CGFloat { 28 }
|
||||||
return .red.opacity(0.18)
|
private var heroPadH: CGFloat { 50 }
|
||||||
}
|
private var heroPadV: CGFloat { 40 }
|
||||||
return .black.opacity(0.26)
|
private var heroSpacing: CGFloat { 24 }
|
||||||
}
|
private var logoSize: CGFloat { 120 }
|
||||||
|
private var scoreCenterWidth: CGFloat { 280 }
|
||||||
|
private var teamInfoGap: CGFloat { 12 }
|
||||||
|
private var pitcherHeadshotSize: CGFloat { 36 }
|
||||||
|
|
||||||
|
private var mainScoreFont: Font { .system(size: 96, weight: .black, design: .rounded) }
|
||||||
|
private var statusTimeFont: Font { .system(size: 48, weight: .black, design: .rounded) }
|
||||||
|
private var inningFont: Font { .system(size: 24, weight: .bold, design: .rounded) }
|
||||||
|
private var teamCodeFont: Font { .system(size: 38, weight: .black, design: .rounded) }
|
||||||
|
private var teamNameFont: Font { .system(size: 24, weight: .bold) }
|
||||||
|
private var recordFont: Font { .system(size: 22, weight: .bold, design: .monospaced) }
|
||||||
|
private var subtitleFont: Font { .system(size: 22, weight: .medium) }
|
||||||
|
private var chipFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
||||||
|
private var labelFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
|
||||||
|
private var probableLabelFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
||||||
|
private var pitcherNameFont: Font { .system(size: 22, weight: .semibold) }
|
||||||
|
#else
|
||||||
|
private var heroRadius: CGFloat { 22 }
|
||||||
|
private var heroPadH: CGFloat { 24 }
|
||||||
|
private var heroPadV: CGFloat { 24 }
|
||||||
|
private var heroSpacing: CGFloat { 16 }
|
||||||
|
private var logoSize: CGFloat { 64 }
|
||||||
|
private var scoreCenterWidth: CGFloat { 160 }
|
||||||
|
private var teamInfoGap: CGFloat { 8 }
|
||||||
|
private var pitcherHeadshotSize: CGFloat { 28 }
|
||||||
|
|
||||||
|
private var mainScoreFont: Font { .system(size: 56, weight: .black, design: .rounded) }
|
||||||
|
private var statusTimeFont: Font { .system(size: 32, weight: .black, design: .rounded) }
|
||||||
|
private var inningFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
|
||||||
|
private var teamCodeFont: Font { .system(size: 24, weight: .black, design: .rounded) }
|
||||||
|
private var teamNameFont: Font { .system(size: 16, weight: .bold) }
|
||||||
|
private var recordFont: Font { .system(size: 13, weight: .bold, design: .monospaced) }
|
||||||
|
private var subtitleFont: Font { .system(size: 14, weight: .medium) }
|
||||||
|
private var chipFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
||||||
|
private var labelFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
|
||||||
|
private var probableLabelFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
|
||||||
|
private var pitcherNameFont: Font { .system(size: 14, weight: .semibold) }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,18 +35,11 @@ struct FeedView: View {
|
|||||||
if viewModel.highlights.isEmpty && !viewModel.isLoading {
|
if viewModel.highlights.isEmpty && !viewModel.isLoading {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
// All highlights in one horizontal scroll, ordered by time
|
LazyVStack(spacing: DS.Spacing.cardGap) {
|
||||||
ScrollView(.horizontal) {
|
ForEach(viewModel.highlights) { item in
|
||||||
LazyHStack(spacing: DS.Spacing.cardGap) {
|
highlightCard(item)
|
||||||
ForEach(viewModel.highlights) { item in
|
|
||||||
highlightCard(item)
|
|
||||||
.frame(width: cardWidth)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
.platformFocusSection()
|
|
||||||
.scrollClipDisabled()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, edgeInset)
|
.padding(.horizontal, edgeInset)
|
||||||
@@ -79,8 +72,8 @@ struct FeedView: View {
|
|||||||
Button {
|
Button {
|
||||||
playingURL = item.hlsURL ?? item.mp4URL
|
playingURL = item.hlsURL ?? item.mp4URL
|
||||||
} label: {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
HStack(spacing: 16) {
|
||||||
// Thumbnail area with team colors
|
// Thumbnail
|
||||||
ZStack {
|
ZStack {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Rectangle().fill(TeamAssets.color(for: item.awayCode).opacity(0.3))
|
Rectangle().fill(TeamAssets.color(for: item.awayCode).opacity(0.3))
|
||||||
@@ -101,21 +94,19 @@ struct FeedView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play icon overlay
|
|
||||||
Image(systemName: "play.circle.fill")
|
Image(systemName: "play.circle.fill")
|
||||||
.font(.system(size: playIconSize))
|
.font(.system(size: playIconSize))
|
||||||
.foregroundStyle(.white.opacity(0.8))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
.shadow(radius: 4)
|
.shadow(radius: 4)
|
||||||
|
|
||||||
// Condensed/Recap badge
|
|
||||||
if item.isCondensedGame {
|
if item.isCondensedGame {
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("CONDENSED")
|
Text("CONDENSED")
|
||||||
.font(DS.Fonts.caption)
|
.font(badgeFont)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.kerning(0.8)
|
.kerning(0.5)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.background(DS.Colors.media)
|
.background(DS.Colors.media)
|
||||||
@@ -126,22 +117,31 @@ struct FeedView: View {
|
|||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: thumbnailHeight)
|
.frame(width: thumbnailWidth, height: thumbnailHeight)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
|
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
|
||||||
|
|
||||||
// Info
|
// Info
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(item.gameTitle)
|
HStack {
|
||||||
.font(DS.Fonts.caption)
|
Text(item.gameTitle)
|
||||||
.foregroundStyle(DS.Colors.textTertiary)
|
.font(gameTagFont)
|
||||||
.kerning(1)
|
.foregroundStyle(DS.Colors.textTertiary)
|
||||||
|
.kerning(0.8)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(timeAgo(item.timestamp))
|
||||||
|
.font(gameTagFont)
|
||||||
|
.foregroundStyle(DS.Colors.textQuaternary)
|
||||||
|
}
|
||||||
|
|
||||||
Text(item.headline)
|
Text(item.headline)
|
||||||
.font(headlineFont)
|
.font(headlineFont)
|
||||||
.foregroundStyle(DS.Colors.textPrimary)
|
.foregroundStyle(DS.Colors.textPrimary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(DS.Spacing.panelPadCompact)
|
.padding(DS.Spacing.panelPadCompact)
|
||||||
.background(DS.Colors.panelFill)
|
.background(DS.Colors.panelFill)
|
||||||
@@ -172,23 +172,35 @@ struct FeedView: View {
|
|||||||
|
|
||||||
// MARK: - Platform sizing
|
// MARK: - Platform sizing
|
||||||
|
|
||||||
|
private func timeAgo(_ date: Date) -> String {
|
||||||
|
let interval = Date().timeIntervalSince(date)
|
||||||
|
if interval < 60 { return "Just now" }
|
||||||
|
if interval < 3600 { return "\(Int(interval / 60))m ago" }
|
||||||
|
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
|
||||||
|
return "\(Int(interval / 86400))d ago"
|
||||||
|
}
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
private var edgeInset: CGFloat { 60 }
|
private var edgeInset: CGFloat { 60 }
|
||||||
private var cardWidth: CGFloat { 420 }
|
private var thumbnailWidth: CGFloat { 300 }
|
||||||
private var thumbnailHeight: CGFloat { 200 }
|
private var thumbnailHeight: CGFloat { 160 }
|
||||||
private var thumbnailLogoSize: CGFloat { 56 }
|
private var thumbnailLogoSize: CGFloat { 48 }
|
||||||
private var thumbnailLogoGap: CGFloat { 24 }
|
private var thumbnailLogoGap: CGFloat { 20 }
|
||||||
private var playIconSize: CGFloat { 44 }
|
private var playIconSize: CGFloat { 40 }
|
||||||
private var atFontSize: CGFloat { 20 }
|
private var atFontSize: CGFloat { 22 }
|
||||||
private var headlineFont: Font { .system(size: 18, weight: .semibold) }
|
private var headlineFont: Font { .system(size: 24, weight: .semibold) }
|
||||||
|
private var gameTagFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
|
||||||
|
private var badgeFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
||||||
#else
|
#else
|
||||||
private var edgeInset: CGFloat { 20 }
|
private var edgeInset: CGFloat { 20 }
|
||||||
private var cardWidth: CGFloat { 280 }
|
private var thumbnailWidth: CGFloat { 180 }
|
||||||
private var thumbnailHeight: CGFloat { 140 }
|
private var thumbnailHeight: CGFloat { 100 }
|
||||||
private var thumbnailLogoSize: CGFloat { 40 }
|
private var thumbnailLogoSize: CGFloat { 32 }
|
||||||
private var thumbnailLogoGap: CGFloat { 16 }
|
private var thumbnailLogoGap: CGFloat { 12 }
|
||||||
private var playIconSize: CGFloat { 32 }
|
private var playIconSize: CGFloat { 28 }
|
||||||
private var atFontSize: CGFloat { 15 }
|
private var atFontSize: CGFloat { 14 }
|
||||||
private var headlineFont: Font { .system(size: 15, weight: .semibold) }
|
private var headlineFont: Font { .system(size: 15, weight: .semibold) }
|
||||||
|
private var gameTagFont: Font { .system(size: 12, weight: .bold, design: .rounded) }
|
||||||
|
private var badgeFont: Font { .system(size: 11, weight: .bold, design: .rounded) }
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,298 +16,183 @@ struct GameCardView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: onSelect) {
|
Button(action: onSelect) {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
header
|
// Team color accent bar
|
||||||
.padding(.horizontal, 22)
|
HStack(spacing: 0) {
|
||||||
.padding(.top, 18)
|
Rectangle().fill(awayColor)
|
||||||
.padding(.bottom, 16)
|
Rectangle().fill(homeColor)
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
teamRow(team: game.awayTeam, isWinning: isWinning(away: true))
|
|
||||||
teamRow(team: game.homeTeam, isWinning: isWinning(away: false))
|
|
||||||
}
|
}
|
||||||
.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 {
|
if let linescore = game.linescore, !game.status.isScheduled {
|
||||||
|
Divider()
|
||||||
|
.frame(height: dividerHeight)
|
||||||
|
.background(.white.opacity(0.08))
|
||||||
|
|
||||||
MiniLinescoreView(
|
MiniLinescoreView(
|
||||||
linescore: linescore,
|
linescore: linescore,
|
||||||
awayCode: game.awayTeam.code,
|
awayCode: game.awayTeam.code,
|
||||||
homeCode: game.homeTeam.code
|
homeCode: game.homeTeam.code
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 22)
|
.padding(.horizontal, miniLSPad)
|
||||||
.padding(.top, 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
.background(cardBackground)
|
||||||
.overlay(alignment: .top) {
|
.clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous))
|
||||||
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(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
RoundedRectangle(cornerRadius: cardRadius, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(borderColor, lineWidth: borderWidth)
|
||||||
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()
|
.platformCardStyle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Team Block
|
||||||
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
|
@ViewBuilder
|
||||||
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View {
|
private func teamBlock(team: TeamInfo, isWinning: Bool, trailing: Bool = false) -> some View {
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: teamInnerSpacing) {
|
||||||
TeamLogoView(team: team, size: 46)
|
if trailing { Spacer(minLength: 0) }
|
||||||
.frame(width: 50, height: 50)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
TeamLogoView(team: team, size: logoSize)
|
||||||
HStack(spacing: 10) {
|
|
||||||
Text(team.code)
|
|
||||||
.font(.system(size: 28, weight: .black, design: .rounded))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
|
|
||||||
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)
|
Text(record)
|
||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(recordFont)
|
||||||
.foregroundStyle(.white.opacity(0.72))
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 5)
|
if let streak = team.streak {
|
||||||
.background(.white.opacity(isWinning ? 0.12 : 0.07))
|
Text(streak)
|
||||||
.clipShape(Capsule())
|
.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 !trailing { Spacer(minLength: 0) }
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isWinning(away: Bool) -> Bool {
|
// MARK: - Status
|
||||||
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
|
@ViewBuilder
|
||||||
private var compactStatus: some View {
|
private var statusPill: some View {
|
||||||
switch game.status {
|
switch game.status {
|
||||||
case .live(let inning):
|
case .live(let inning):
|
||||||
HStack(spacing: 7) {
|
HStack(spacing: 5) {
|
||||||
Circle()
|
Circle().fill(DS.Colors.live).frame(width: 6, height: 6)
|
||||||
.fill(.red)
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
Text(inning ?? "LIVE")
|
Text(inning ?? "LIVE")
|
||||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
.font(statusFont)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 5)
|
||||||
.background(.red.opacity(0.18))
|
.background(DS.Colors.live.opacity(0.18))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
|
|
||||||
case .scheduled(let time):
|
case .scheduled(let time):
|
||||||
Text(time)
|
Text(time)
|
||||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
.font(statusFont)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(.white.opacity(0.12))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
|
|
||||||
case .final_:
|
case .final_:
|
||||||
Text("FINAL")
|
Text("FINAL")
|
||||||
.font(.system(size: 13, weight: .black, design: .rounded))
|
.font(statusFont)
|
||||||
.foregroundStyle(.white.opacity(0.92))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
.background(.white.opacity(0.12))
|
|
||||||
.clipShape(Capsule())
|
|
||||||
|
|
||||||
case .unknown:
|
case .unknown:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
// MARK: - Helpers
|
||||||
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)
|
private func isWinning(away: Bool) -> Bool {
|
||||||
|
guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false }
|
||||||
if inMultiView {
|
return away ? a > h : h > a
|
||||||
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 {
|
private var cardBackground: some ShapeStyle {
|
||||||
if game.status.isScheduled {
|
Color(red: 0.06, green: 0.07, blue: 0.10)
|
||||||
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 {
|
private var borderColor: Color {
|
||||||
if game.isBlackedOut { return "eye.slash.fill" }
|
if inMultiView { return .green.opacity(0.35) }
|
||||||
if game.hasStreams { return "tv.fill" }
|
if game.isLive { return DS.Colors.live.opacity(0.3) }
|
||||||
if game.status.isScheduled { return "mappin.and.ellipse" }
|
return .white.opacity(0.06)
|
||||||
return "sportscourt.fill"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private var borderWidth: CGFloat {
|
||||||
private func footerBadge(title: String, color: Color) -> some View {
|
inMultiView || game.isLive ? 2 : 1
|
||||||
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 {
|
// MARK: - Platform Sizing
|
||||||
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
|
#if os(tvOS)
|
||||||
private var cardBackground: some View {
|
private var cardHeight: CGFloat { 120 }
|
||||||
ZStack {
|
private var cardPadV: CGFloat { 8 }
|
||||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
private var cardRadius: CGFloat { 20 }
|
||||||
.fill(Color(red: 0.08, green: 0.09, blue: 0.12))
|
private var logoSize: CGFloat { 40 }
|
||||||
|
private var teamBlockSpacing: CGFloat { 12 }
|
||||||
LinearGradient(
|
private var teamInnerSpacing: CGFloat { 12 }
|
||||||
colors: [
|
private var blockPadH: CGFloat { 18 }
|
||||||
awayColor.opacity(0.18),
|
private var scoreCenterWidth: CGFloat { 140 }
|
||||||
Color.clear,
|
private var dividerHeight: CGFloat { 60 }
|
||||||
homeColor.opacity(0.18)
|
private var miniLSPad: CGFloat { 16 }
|
||||||
],
|
private var scoreFont: Font { .system(size: 30, weight: .black, design: .rounded) }
|
||||||
startPoint: .topLeading,
|
private var codeFont: Font { .system(size: 24, weight: .black, design: .rounded) }
|
||||||
endPoint: .bottomTrailing
|
private var recordFont: Font { .system(size: 18, weight: .bold, design: .monospaced) }
|
||||||
)
|
private var statusFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
||||||
|
#else
|
||||||
Circle()
|
private var cardHeight: CGFloat { 90 }
|
||||||
.fill(awayColor.opacity(0.18))
|
private var cardPadV: CGFloat { 6 }
|
||||||
.frame(width: 180)
|
private var cardRadius: CGFloat { 16 }
|
||||||
.blur(radius: 40)
|
private var logoSize: CGFloat { 30 }
|
||||||
.offset(x: -110, y: -90)
|
private var teamBlockSpacing: CGFloat { 8 }
|
||||||
|
private var teamInnerSpacing: CGFloat { 8 }
|
||||||
Circle()
|
private var blockPadH: CGFloat { 12 }
|
||||||
.fill(homeColor.opacity(0.16))
|
private var scoreCenterWidth: CGFloat { 100 }
|
||||||
.frame(width: 200)
|
private var dividerHeight: CGFloat { 44 }
|
||||||
.blur(radius: 44)
|
private var miniLSPad: CGFloat { 10 }
|
||||||
.offset(x: 140, y: 120)
|
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) }
|
||||||
private var shadowColor: Color {
|
#endif
|
||||||
if inMultiView { return .green.opacity(0.18) }
|
|
||||||
if game.isLive { return .red.opacity(0.22) }
|
|
||||||
return .black.opacity(0.22)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,24 @@ struct LeagueCenterView: View {
|
|||||||
messagePanel(overviewErrorMessage, tint: .orange)
|
messagePanel(overviewErrorMessage, tint: .orange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
// Side-by-side: standings left, leaders right
|
||||||
|
HStack(alignment: .top, spacing: 24) {
|
||||||
|
standingsSection
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
if !viewModel.leagueLeaders.isEmpty {
|
||||||
|
leadersColumnSection
|
||||||
|
.frame(width: 420)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
standingsSection
|
standingsSection
|
||||||
|
|
||||||
// League Leaders
|
|
||||||
if !viewModel.leagueLeaders.isEmpty {
|
if !viewModel.leagueLeaders.isEmpty {
|
||||||
leadersSection
|
leadersSection
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
teamsSection
|
teamsSection
|
||||||
|
|
||||||
@@ -230,6 +242,33 @@ struct LeagueCenterView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
private var leadersColumnSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack {
|
||||||
|
Text("Leaders")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if viewModel.isLoadingLeaders {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: DS.Spacing.cardGap) {
|
||||||
|
ForEach(viewModel.leagueLeaders.prefix(4)) { category in
|
||||||
|
LeaderboardView(category: category)
|
||||||
|
.platformFocusable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private var leadersSection: some View {
|
private var leadersSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
Reference in New Issue
Block a user