diff --git a/mlbTVOS/Views/Components/DesignSystem.swift b/mlbTVOS/Views/Components/DesignSystem.swift index 76b6f19..cc4c08f 100644 --- a/mlbTVOS/Views/Components/DesignSystem.swift +++ b/mlbTVOS/Views/Components/DesignSystem.swift @@ -40,14 +40,14 @@ enum DS { static let bodySmall = Font.system(size: 13, weight: .medium) 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) - static let tvSectionTitle = Font.system(size: 36, weight: .bold, design: .rounded) - static let tvCardTitle = Font.system(size: 26, weight: .bold, design: .rounded) - static let tvScore = Font.system(size: 34, weight: .black, design: .rounded).monospacedDigit() - static let tvDataValue = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit() - static let tvBody = Font.system(size: 20, weight: .medium) - static let tvCaption = Font.system(size: 15, weight: .bold, design: .rounded) + static let tvSectionTitle = Font.system(size: 38, weight: .bold, design: .rounded) + static let tvCardTitle = Font.system(size: 28, weight: .bold, design: .rounded) + static let tvScore = Font.system(size: 36, weight: .black, design: .rounded).monospacedDigit() + static let tvDataValue = Font.system(size: 24, weight: .bold, design: .rounded).monospacedDigit() + static let tvBody = Font.system(size: 24, weight: .medium) + static let tvCaption = Font.system(size: 22, weight: .bold, design: .rounded) #endif } @@ -87,10 +87,15 @@ enum DS { struct DataLabelStyle: ViewModifier { func body(content: Content) -> some View { content + #if os(tvOS) + .font(DS.Fonts.tvCaption) + .kerning(1.0) + #else .font(DS.Fonts.caption) + .kerning(1.5) + #endif .foregroundStyle(DS.Colors.textQuaternary) .textCase(.uppercase) - .kerning(1.5) } } diff --git a/mlbTVOS/Views/Components/PlatformUI.swift b/mlbTVOS/Views/Components/PlatformUI.swift index 6679abf..5620f90 100644 --- a/mlbTVOS/Views/Components/PlatformUI.swift +++ b/mlbTVOS/Views/Components/PlatformUI.swift @@ -1,5 +1,7 @@ import SwiftUI +// MARK: - iOS Press Style + struct PlatformPressButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { 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 { @ViewBuilder func platformCardStyle() -> some View { #if os(tvOS) - self.buttonStyle(.card) + self.buttonStyle(TVFocusButtonStyle()) #else self.buttonStyle(PlatformPressButtonStyle()) #endif diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index 5bb5b04..85d5d82 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -84,9 +84,9 @@ struct DashboardView: View { private var shelfCardWidth: CGFloat { #if os(iOS) - horizontalSizeClass == .compact ? 300 : 360 + horizontalSizeClass == .compact ? 340 : 480 #else - 400 + 580 #endif } diff --git a/mlbTVOS/Views/FeaturedGameCard.swift b/mlbTVOS/Views/FeaturedGameCard.swift index a0d79fa..01c3bdd 100644 --- a/mlbTVOS/Views/FeaturedGameCard.swift +++ b/mlbTVOS/Views/FeaturedGameCard.swift @@ -7,14 +7,9 @@ struct FeaturedGameCard: View { private var awayColor: Color { TeamAssets.color(for: game.awayTeam.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? { guard let pitchers = game.pitchers else { return nil } - let parts = pitchers.components(separatedBy: " vs ") - return parts.first + return pitchers.components(separatedBy: " vs ").first } private var homePitcherName: String? { @@ -26,370 +21,229 @@ struct FeaturedGameCard: View { var body: some View { Button(action: onSelect) { VStack(spacing: 0) { - ViewThatFits { - HStack(alignment: .top, spacing: 28) { - matchupColumn - .frame(maxWidth: .infinity, alignment: .leading) + // Full-bleed matchup area + ZStack { + heroBackground - sidePanel - .frame(width: 760, alignment: .leading) + VStack(spacing: heroSpacing) { + // 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 } - - VStack(alignment: .leading, spacing: 24) { - 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) + .padding(.horizontal, heroPadH) + .padding(.vertical, heroPadV) } - statusChip - } - } - } - - @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)) - } - + // Linescore bar (if game started) + if let linescore = game.linescore, !game.status.isScheduled { LinescoreView( linescore: linescore, awayCode: game.awayTeam.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 { - 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 - 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) - } + // MARK: - Subtitle Row @ViewBuilder - private var fallbackInfoPanel: some View { - VStack(alignment: .leading, spacing: 12) { - 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) - + private var subtitleRow: some View { + HStack(spacing: 16) { if let venue = game.venue { - Text(venue) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white.opacity(0.52)) + Label(venue, systemImage: "mappin.and.ellipse") + .font(subtitleFont) + .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() - HStack(spacing: 8) { - Image(systemName: game.hasStreams ? "play.fill" : "rectangle.and.text.magnifyingglass") - .font(.system(size: 13, weight: .bold)) - Text(game.hasStreams ? "Watch Game" : "Open Matchup") - .font(.system(size: 15, weight: .bold)) + if game.hasStreams { + Label("\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", systemImage: "tv.fill") + .font(subtitleFont) + .foregroundStyle(DS.Colors.interactive.opacity(0.9)) + } 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 private var statusChip: some View { switch game.status { case .live(let inning): - HStack(spacing: 8) { - Circle() - .fill(.red) - .frame(width: 8, height: 8) + HStack(spacing: 6) { + Circle().fill(DS.Colors.live).frame(width: 8, height: 8) Text(inning ?? "LIVE") - .font(.system(size: 15, weight: .black, design: .rounded)) + .font(chipFont) .foregroundStyle(.white) } - .padding(.horizontal, 15) - .padding(.vertical, 10) - .background(.red.opacity(0.18)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(DS.Colors.live.opacity(0.2)) .clipShape(Capsule()) case .scheduled(let time): Text(time) - .font(.system(size: 15, weight: .black, design: .rounded)) + .font(chipFont) .foregroundStyle(.white) - .padding(.horizontal, 15) - .padding(.vertical, 10) + .padding(.horizontal, 14) + .padding(.vertical, 8) .background(.white.opacity(0.1)) .clipShape(Capsule()) case .final_: Text("FINAL") - .font(.system(size: 15, weight: .black, design: .rounded)) - .foregroundStyle(.white.opacity(0.96)) - .padding(.horizontal, 15) - .padding(.vertical, 10) - .background(.white.opacity(0.12)) + .font(chipFont) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.white.opacity(0.1)) .clipShape(Capsule()) case .unknown: @@ -397,196 +251,85 @@ struct FeaturedGameCard: View { } } - @ViewBuilder - 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 - ) - } + // MARK: - Background @ViewBuilder - private var panelBackground: 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 { + private var heroBackground: some View { ZStack { - RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(Color(red: 0.05, green: 0.07, blue: 0.11)) + // Base + Color(red: 0.04, green: 0.05, blue: 0.08) - LinearGradient( - colors: [ - awayColor.opacity(0.2), - Color(red: 0.05, green: 0.07, blue: 0.11), - homeColor.opacity(0.22) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + // Team color washes + HStack(spacing: 0) { + awayColor.opacity(0.25) + Color.clear + homeColor.opacity(0.25) + } + + // Glow orbs + Circle() + .fill(awayColor.opacity(0.2)) + .frame(width: 400, height: 400) + .blur(radius: 100) + .offset(x: -300, y: -50) Circle() - .fill(awayColor.opacity(0.18)) - .frame(width: 320, height: 320) - .blur(radius: 64) - .offset(x: -280, y: -70) - - 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) + .fill(homeColor.opacity(0.2)) + .frame(width: 400, height: 400) + .blur(radius: 100) + .offset(x: 300, y: 50) } } private var borderColor: Color { - if game.isLive { - return .red.opacity(0.32) - } - if game.hasStreams { - return .blue.opacity(0.24) - } - return .white.opacity(0.08) + if game.isLive { return DS.Colors.live.opacity(0.35) } + if game.hasStreams { return DS.Colors.interactive.opacity(0.2) } + return .white.opacity(0.06) } - private var borderWidth: CGFloat { - game.isLive || game.hasStreams ? 2 : 1 - } + // MARK: - Platform Sizing - private var shadowColor: Color { - if game.isLive { - return .red.opacity(0.18) - } - return .black.opacity(0.26) - } + #if os(tvOS) + private var heroRadius: CGFloat { 28 } + private var heroPadH: CGFloat { 50 } + private var heroPadV: CGFloat { 40 } + 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 } diff --git a/mlbTVOS/Views/FeedView.swift b/mlbTVOS/Views/FeedView.swift index 10231e3..3f0b12c 100644 --- a/mlbTVOS/Views/FeedView.swift +++ b/mlbTVOS/Views/FeedView.swift @@ -35,18 +35,11 @@ struct FeedView: View { if viewModel.highlights.isEmpty && !viewModel.isLoading { emptyState } else { - // All highlights in one horizontal scroll, ordered by time - ScrollView(.horizontal) { - LazyHStack(spacing: DS.Spacing.cardGap) { - ForEach(viewModel.highlights) { item in - highlightCard(item) - .frame(width: cardWidth) - } + LazyVStack(spacing: DS.Spacing.cardGap) { + ForEach(viewModel.highlights) { item in + highlightCard(item) } - .padding(.vertical, 8) } - .platformFocusSection() - .scrollClipDisabled() } } .padding(.horizontal, edgeInset) @@ -79,8 +72,8 @@ struct FeedView: View { Button { playingURL = item.hlsURL ?? item.mp4URL } label: { - VStack(alignment: .leading, spacing: 10) { - // Thumbnail area with team colors + HStack(spacing: 16) { + // Thumbnail ZStack { HStack(spacing: 0) { 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") .font(.system(size: playIconSize)) - .foregroundStyle(.white.opacity(0.8)) + .foregroundStyle(.white.opacity(0.7)) .shadow(radius: 4) - // Condensed/Recap badge if item.isCondensedGame { VStack { HStack { Spacer() Text("CONDENSED") - .font(DS.Fonts.caption) + .font(badgeFont) .foregroundStyle(.white) - .kerning(0.8) + .kerning(0.5) .padding(.horizontal, 8) .padding(.vertical, 4) .background(DS.Colors.media) @@ -126,22 +117,31 @@ struct FeedView: View { .padding(8) } } - .frame(height: thumbnailHeight) + .frame(width: thumbnailWidth, height: thumbnailHeight) .clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact)) // Info - VStack(alignment: .leading, spacing: 4) { - Text(item.gameTitle) - .font(DS.Fonts.caption) - .foregroundStyle(DS.Colors.textTertiary) - .kerning(1) + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(item.gameTitle) + .font(gameTagFont) + .foregroundStyle(DS.Colors.textTertiary) + .kerning(0.8) + + Spacer() + + Text(timeAgo(item.timestamp)) + .font(gameTagFont) + .foregroundStyle(DS.Colors.textQuaternary) + } Text(item.headline) .font(headlineFont) .foregroundStyle(DS.Colors.textPrimary) .lineLimit(2) } - .padding(.horizontal, 4) + + Spacer(minLength: 0) } .padding(DS.Spacing.panelPadCompact) .background(DS.Colors.panelFill) @@ -172,23 +172,35 @@ struct FeedView: View { // 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) private var edgeInset: CGFloat { 60 } - private var cardWidth: CGFloat { 420 } - private var thumbnailHeight: CGFloat { 200 } - private var thumbnailLogoSize: CGFloat { 56 } - private var thumbnailLogoGap: CGFloat { 24 } - private var playIconSize: CGFloat { 44 } - private var atFontSize: CGFloat { 20 } - private var headlineFont: Font { .system(size: 18, weight: .semibold) } + private var thumbnailWidth: CGFloat { 300 } + private var thumbnailHeight: CGFloat { 160 } + private var thumbnailLogoSize: CGFloat { 48 } + private var thumbnailLogoGap: CGFloat { 20 } + private var playIconSize: CGFloat { 40 } + private var atFontSize: CGFloat { 22 } + 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 private var edgeInset: CGFloat { 20 } - private var cardWidth: CGFloat { 280 } - private var thumbnailHeight: CGFloat { 140 } - private var thumbnailLogoSize: CGFloat { 40 } - private var thumbnailLogoGap: CGFloat { 16 } - private var playIconSize: CGFloat { 32 } - private var atFontSize: CGFloat { 15 } + private var thumbnailWidth: CGFloat { 180 } + private var thumbnailHeight: CGFloat { 100 } + private var thumbnailLogoSize: CGFloat { 32 } + private var thumbnailLogoGap: CGFloat { 12 } + private var playIconSize: CGFloat { 28 } + private var atFontSize: CGFloat { 14 } 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 } diff --git a/mlbTVOS/Views/GameCardView.swift b/mlbTVOS/Views/GameCardView.swift index 1d115d2..06adf27 100644 --- a/mlbTVOS/Views/GameCardView.swift +++ b/mlbTVOS/Views/GameCardView.swift @@ -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 } diff --git a/mlbTVOS/Views/LeagueCenterView.swift b/mlbTVOS/Views/LeagueCenterView.swift index 576b612..ec875fd 100644 --- a/mlbTVOS/Views/LeagueCenterView.swift +++ b/mlbTVOS/Views/LeagueCenterView.swift @@ -34,12 +34,24 @@ struct LeagueCenterView: View { 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 - // League Leaders if !viewModel.leagueLeaders.isEmpty { leadersSection } + #endif 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 { VStack(alignment: .leading, spacing: 18) { HStack {