diff --git a/mlbTVOS/Views/Components/CategoryPillBar.swift b/mlbTVOS/Views/Components/CategoryPillBar.swift index 673fb53..cf9505a 100644 --- a/mlbTVOS/Views/Components/CategoryPillBar.swift +++ b/mlbTVOS/Views/Components/CategoryPillBar.swift @@ -3,77 +3,223 @@ import SwiftUI struct CategoryPillBar: View { @Binding var selected: AppSection var streamCount: Int = 0 + var totalGames: Int = 0 + var liveGames: Int = 0 + + @Namespace private var selectionNamespace + + private var primarySections: [AppSection] { + AppSection.allCases.filter { $0 != .settings } + } var body: some View { - HStack(spacing: pillSpacing) { - ForEach(AppSection.allCases) { section in - if section == .multiView { - // Multi-View as a separate icon button with badge - Button { - selected = .multiView - } label: { - HStack(spacing: 6) { - Image(systemName: "rectangle.split.2x2.fill") - .font(.system(size: iconSize, weight: .semibold)) - if streamCount > 0 { - Text("\(streamCount)") - .font(pillFont.weight(.black)) - } - } - .foregroundStyle(selected == .multiView ? .white : DS.Colors.textTertiary) - .padding(.horizontal, pillPadH) - .padding(.vertical, pillPadV) - .background( - Capsule().fill(selected == .multiView ? DS.Colors.interactive : .clear) - ) - } - .platformCardStyle() - } else if section == .settings { - Spacer() - Button { - selected = .settings - } label: { - Image(systemName: "gearshape.fill") - .font(.system(size: iconSize, weight: .semibold)) - .foregroundStyle(selected == .settings ? .white : DS.Colors.textTertiary) - .padding(.horizontal, pillPadH) - .padding(.vertical, pillPadV) - .background( - Capsule().fill(selected == .settings ? DS.Colors.interactive : .clear) - ) - } - .platformCardStyle() - } else { - Button { - selected = section - } label: { - Text(section.title) - .font(pillFont) - .foregroundStyle(selected == section ? .white : DS.Colors.textTertiary) - .padding(.horizontal, pillPadH) - .padding(.vertical, pillPadV) - .background( - Capsule().fill(selected == section ? DS.Colors.interactive : .clear) - ) - } - .platformCardStyle() - } - } + ViewThatFits { + expandedBar + compactBar } } + private var expandedBar: some View { + HStack(spacing: 24) { + brandLockup + + HStack(spacing: 10) { + ForEach(primarySections) { section in + sectionButton(section) + } + } + + Spacer(minLength: 12) + + HStack(spacing: 10) { + statusChip(value: "\(liveGames)", label: "Live", systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live) + statusChip(value: "\(totalGames)", label: "Games", systemImage: "sportscourt", tint: DS.Colors.media) + statusChip(value: "\(streamCount)", label: "Tiles", systemImage: "rectangle.split.2x2", tint: DS.Colors.positive) + settingsButton + } + } + .padding(.horizontal, containerPadH) + .padding(.vertical, containerPadV) + .background(shellBackground) + } + + private var compactBar: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 16) { + brandLockup + Spacer() + settingsButton + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(primarySections) { section in + sectionButton(section) + } + statusChip(value: "\(liveGames)", label: "Live", systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live) + statusChip(value: "\(totalGames)", label: "Games", systemImage: "sportscourt", tint: DS.Colors.media) + statusChip(value: "\(streamCount)", label: "Tiles", systemImage: "rectangle.split.2x2", tint: DS.Colors.positive) + } + } + .scrollClipDisabled() + } + .padding(.horizontal, containerPadH) + .padding(.vertical, containerPadV) + .background(shellBackground) + } + + private var brandLockup: some View { + VStack(alignment: .leading, spacing: 3) { + Text("MLB") + .font(brandPrimaryFont) + .foregroundStyle(DS.Colors.textPrimary) + + Text("CONTROL ROOM") + .font(brandSecondaryFont) + .foregroundStyle(DS.Colors.textTertiary) + .tracking(brandTracking) + } + } + + private func sectionButton(_ section: AppSection) -> some View { + Button { + withAnimation(.spring(response: 0.36, dampingFraction: 0.82)) { + selected = section + } + } label: { + HStack(spacing: 10) { + Image(systemName: section.systemImage) + .font(.system(size: iconSize, weight: .bold)) + + Text(section.title) + .font(pillFont) + } + .foregroundStyle(selected == section ? Color.black.opacity(0.86) : DS.Colors.textSecondary) + .padding(.horizontal, pillPadH) + .padding(.vertical, pillPadV) + .background { + if selected == section { + Capsule() + .fill( + LinearGradient( + colors: [ + DS.Colors.interactive, + DS.Colors.warning, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .matchedGeometryEffect(id: "selected-section", in: selectionNamespace) + } else { + Capsule() + .fill(DS.Colors.panelFillMuted) + } + } + } + .platformCardStyle() + } + + private var settingsButton: some View { + Button { + selected = .settings + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: iconSize, weight: .bold)) + .foregroundStyle(selected == .settings ? Color.black.opacity(0.86) : DS.Colors.textSecondary) + .padding(.horizontal, pillPadH) + .padding(.vertical, pillPadV) + .background { + if selected == .settings { + Capsule() + .fill( + LinearGradient( + colors: [ + DS.Colors.interactive, + DS.Colors.warning, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } else { + Capsule() + .fill(DS.Colors.panelFillMuted) + } + } + } + .platformCardStyle() + } + + private func statusChip(value: String, label: String, systemImage: String, tint: Color) -> some View { + HStack(spacing: 9) { + Image(systemName: systemImage) + .font(.system(size: statIconSize, weight: .bold)) + .foregroundStyle(tint) + + Text(value) + .font(statValueFont) + .foregroundStyle(DS.Colors.textPrimary) + .monospacedDigit() + + Text(label) + .font(statLabelFont) + .foregroundStyle(DS.Colors.textTertiary) + } + .padding(.horizontal, statPadH) + .padding(.vertical, statPadV) + .background( + Capsule() + .fill(DS.Colors.panelFillMuted) + .overlay { + Capsule() + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } + ) + } + + private var shellBackground: some View { + RoundedRectangle(cornerRadius: shellRadius, style: .continuous) + .fill(DS.Colors.navFill) + .overlay { + RoundedRectangle(cornerRadius: shellRadius, style: .continuous) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } + .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) + } + #if os(tvOS) - private var pillSpacing: CGFloat { 8 } - private var pillPadH: CGFloat { 28 } - private var pillPadV: CGFloat { 14 } - private var pillFont: Font { .system(size: 24, weight: .bold, design: .rounded) } - private var iconSize: CGFloat { 22 } + private var shellRadius: CGFloat { 28 } + private var containerPadH: CGFloat { 28 } + private var containerPadV: CGFloat { 22 } + private var pillPadH: CGFloat { 24 } + private var pillPadV: CGFloat { 16 } + private var statPadH: CGFloat { 18 } + private var statPadV: CGFloat { 14 } + private var pillFont: Font { .system(size: 23, weight: .bold, design: .rounded) } + private var statValueFont: Font { .system(size: 22, weight: .black, design: .rounded) } + private var statLabelFont: Font { .system(size: 16, weight: .bold, design: .rounded) } + private var brandPrimaryFont: Font { .system(size: 34, weight: .black, design: .rounded) } + private var brandSecondaryFont: Font { .system(size: 14, weight: .black, design: .rounded) } + private var brandTracking: CGFloat { 2.6 } + private var iconSize: CGFloat { 20 } + private var statIconSize: CGFloat { 13 } #else - private var pillSpacing: CGFloat { 4 } + private var shellRadius: CGFloat { 22 } + private var containerPadH: CGFloat { 18 } + private var containerPadV: CGFloat { 16 } private var pillPadH: CGFloat { 18 } private var pillPadV: CGFloat { 10 } + private var statPadH: CGFloat { 12 } + private var statPadV: CGFloat { 10 } private var pillFont: Font { .system(size: 15, weight: .bold, design: .rounded) } - private var iconSize: CGFloat { 16 } + private var statValueFont: Font { .system(size: 15, weight: .black, design: .rounded) } + private var statLabelFont: Font { .system(size: 11, weight: .bold, design: .rounded) } + private var brandPrimaryFont: Font { .system(size: 20, weight: .black, design: .rounded) } + private var brandSecondaryFont: Font { .system(size: 10, weight: .black, design: .rounded) } + private var brandTracking: CGFloat { 1.8 } + private var iconSize: CGFloat { 15 } + private var statIconSize: CGFloat { 11 } #endif } @@ -88,11 +234,21 @@ enum AppSection: String, CaseIterable, Identifiable { var title: String { switch self { - case .today: "Today" - case .intel: "Intel" + case .today: "Dashboard" + case .intel: "League" case .highlights: "Highlights" case .multiView: "Multi-View" case .settings: "Settings" } } + + var systemImage: String { + switch self { + case .today: "rectangle.3.group.fill" + case .intel: "chart.xyaxis.line" + case .highlights: "play.rectangle.on.rectangle.fill" + case .multiView: "rectangle.split.2x2.fill" + case .settings: "gearshape.fill" + } + } } diff --git a/mlbTVOS/Views/Components/DataPanel.swift b/mlbTVOS/Views/Components/DataPanel.swift index c3ffe5a..fa25c2b 100644 --- a/mlbTVOS/Views/Components/DataPanel.swift +++ b/mlbTVOS/Views/Components/DataPanel.swift @@ -32,9 +32,9 @@ struct DataPanel: View { if let code = teamAccentCode { RoundedRectangle(cornerRadius: 1.5) .fill(TeamAssets.color(for: code)) - .frame(width: 3) - .padding(.vertical, 6) - .padding(.leading, 4) + .frame(width: 4) + .padding(.vertical, 8) + .padding(.leading, 6) } VStack(alignment: .leading, spacing: 0) { @@ -43,15 +43,24 @@ struct DataPanel: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(density.padding) } - .background( - RoundedRectangle(cornerRadius: density.cornerRadius) - .fill(DS.Colors.panelFill) + .background { + RoundedRectangle(cornerRadius: density.cornerRadius, style: .continuous) + .fill( + LinearGradient( + colors: [ + DS.Colors.panelFill, + DS.Colors.panelFillMuted, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay { + RoundedRectangle(cornerRadius: density.cornerRadius, style: .continuous) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) - ) - .overlay( - RoundedRectangle(cornerRadius: density.cornerRadius) - .strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5) - ) + } } } diff --git a/mlbTVOS/Views/Components/DesignSystem.swift b/mlbTVOS/Views/Components/DesignSystem.swift index ab2c841..f16ddc4 100644 --- a/mlbTVOS/Views/Components/DesignSystem.swift +++ b/mlbTVOS/Views/Components/DesignSystem.swift @@ -1,52 +1,46 @@ import SwiftUI -// MARK: - The Dugout Design System — Warm Light Theme - enum DS { - // MARK: - Colors - enum Colors { - static let background = Color(red: 0.96, green: 0.95, blue: 0.94) - static let panelFill = Color.white - static let panelStroke = Color.black.opacity(0.06) + static let background = Color(red: 0.03, green: 0.05, blue: 0.10) + static let backgroundElevated = Color(red: 0.06, green: 0.10, blue: 0.18) + static let navFill = Color(red: 0.07, green: 0.10, blue: 0.18).opacity(0.94) + static let panelFill = Color(red: 0.08, green: 0.11, blue: 0.18).opacity(0.94) + static let panelFillMuted = Color(red: 0.10, green: 0.14, blue: 0.23).opacity(0.84) + static let panelStroke = Color.white.opacity(0.09) - static let live = Color(red: 0.92, green: 0.18, blue: 0.18) - static let positive = Color(red: 0.15, green: 0.68, blue: 0.32) - static let warning = Color(red: 0.92, green: 0.50, blue: 0.10) - static let interactive = Color(red: 0.95, green: 0.45, blue: 0.15) // warm orange - static let media = Color(red: 0.50, green: 0.30, blue: 0.80) + static let live = Color(red: 0.94, green: 0.25, blue: 0.28) + static let positive = Color(red: 0.24, green: 0.86, blue: 0.63) + static let warning = Color(red: 0.98, green: 0.76, blue: 0.24) + static let interactive = Color(red: 1.00, green: 0.75, blue: 0.20) + static let media = Color(red: 0.35, green: 0.78, blue: 0.95) - static let textPrimary = Color(red: 0.12, green: 0.12, blue: 0.14) - static let textSecondary = Color(red: 0.12, green: 0.12, blue: 0.14).opacity(0.6) - static let textTertiary = Color(red: 0.12, green: 0.12, blue: 0.14).opacity(0.4) - static let textQuaternary = Color(red: 0.12, green: 0.12, blue: 0.14).opacity(0.2) + static let textPrimary = Color.white.opacity(0.96) + static let textSecondary = Color.white.opacity(0.76) + static let textTertiary = Color.white.opacity(0.50) + static let textQuaternary = Color.white.opacity(0.24) - // For use on dark/image backgrounds (hero, overlays) - static let onDarkPrimary = Color.white - static let onDarkSecondary = Color.white.opacity(0.7) - static let onDarkTertiary = Color.white.opacity(0.45) + static let onDarkPrimary = textPrimary + static let onDarkSecondary = textSecondary + static let onDarkTertiary = textTertiary } - // MARK: - Shadows - enum Shadows { - static let card = Color.black.opacity(0.06) - static let cardRadius: CGFloat = 16 - static let cardY: CGFloat = 4 - static let cardLifted = Color.black.opacity(0.12) - static let cardLiftedRadius: CGFloat = 24 - static let cardLiftedY: CGFloat = 8 + static let card = Color.black.opacity(0.30) + static let cardRadius: CGFloat = 28 + static let cardY: CGFloat = 14 + static let cardLifted = Color.black.opacity(0.44) + static let cardLiftedRadius: CGFloat = 42 + static let cardLiftedY: CGFloat = 18 } - // MARK: - Typography - enum Fonts { static let heroScore = Font.system(size: 72, weight: .black, design: .rounded).monospacedDigit() static let largeScore = Font.system(size: 42, weight: .black, design: .rounded).monospacedDigit() static let score = Font.system(size: 28, weight: .black, design: .rounded).monospacedDigit() static let scoreCompact = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit() - static let sectionTitle = Font.system(size: 28, weight: .bold, design: .rounded) + static let sectionTitle = Font.system(size: 30, weight: .bold, design: .rounded) static let cardTitle = Font.system(size: 20, weight: .bold, design: .rounded) static let cardTitleCompact = Font.system(size: 17, weight: .bold, design: .rounded) @@ -56,26 +50,23 @@ 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 — 22px minimum for readability at 10ft #if os(tvOS) - static let tvHeroScore = Font.system(size: 96, weight: .black, design: .rounded).monospacedDigit() - static let tvSectionTitle = Font.system(size: 38, weight: .bold, design: .rounded) + static let tvHeroScore = Font.system(size: 94, weight: .black, design: .rounded).monospacedDigit() + static let tvSectionTitle = Font.system(size: 40, 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) + static let tvBody = Font.system(size: 22, weight: .medium) + static let tvCaption = Font.system(size: 20, weight: .bold, design: .rounded) #endif } - // MARK: - Spacing - enum Spacing { #if os(tvOS) static let panelPadCompact: CGFloat = 18 static let panelPadStandard: CGFloat = 24 static let panelPadFeatured: CGFloat = 32 - static let sectionGap: CGFloat = 40 + static let sectionGap: CGFloat = 42 static let cardGap: CGFloat = 20 static let itemGap: CGFloat = 12 static let edgeInset: CGFloat = 50 @@ -90,16 +81,82 @@ enum DS { #endif } - // MARK: - Radii - enum Radii { - static let compact: CGFloat = 14 - static let standard: CGFloat = 18 - static let featured: CGFloat = 22 + static let compact: CGFloat = 16 + static let standard: CGFloat = 24 + static let featured: CGFloat = 30 } } -// MARK: - Data Label Style +struct BroadcastBackground: View { + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.03, green: 0.05, blue: 0.10), + Color(red: 0.04, green: 0.08, blue: 0.16), + Color(red: 0.02, green: 0.04, blue: 0.09), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // Subtle color washes — radial gradients instead of blurred circles for performance + RadialGradient( + colors: [Color(red: 0.00, green: 0.46, blue: 0.72).opacity(0.12), .clear], + center: UnitPoint(x: 0.1, y: 0.15), + startRadius: 50, + endRadius: 500 + ) + + RadialGradient( + colors: [DS.Colors.interactive.opacity(0.10), .clear], + center: UnitPoint(x: 0.85, y: 0.15), + startRadius: 50, + endRadius: 450 + ) + + RadialGradient( + colors: [DS.Colors.live.opacity(0.06), .clear], + center: UnitPoint(x: 0.8, y: 0.85), + startRadius: 50, + endRadius: 400 + ) + + BroadcastGridOverlay() + .opacity(0.30) + } + } +} + +private struct BroadcastGridOverlay: View { + var body: some View { + GeometryReader { proxy in + let size = proxy.size + + Path { path in + let verticalSpacing: CGFloat = 110 + let horizontalSpacing: CGFloat = 90 + + var x: CGFloat = 0 + while x <= size.width { + path.move(to: CGPoint(x: x, y: 0)) + path.addLine(to: CGPoint(x: x, y: size.height)) + x += verticalSpacing + } + + var y: CGFloat = 0 + while y <= size.height { + path.move(to: CGPoint(x: 0, y: y)) + path.addLine(to: CGPoint(x: size.width, y: y)) + y += horizontalSpacing + } + } + .stroke(Color.white.opacity(0.05), lineWidth: 1) + } + .allowsHitTesting(false) + } +} struct DataLabelStyle: ViewModifier { func body(content: Content) -> some View { diff --git a/mlbTVOS/Views/Components/PlatformUI.swift b/mlbTVOS/Views/Components/PlatformUI.swift index bbd9968..2fac60e 100644 --- a/mlbTVOS/Views/Components/PlatformUI.swift +++ b/mlbTVOS/Views/Components/PlatformUI.swift @@ -19,7 +19,7 @@ struct TVFocusButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .scaleEffect(configuration.isPressed ? 0.97 : isFocused ? 1.04 : 1.0) + .scaleEffect(configuration.isPressed ? 0.98 : isFocused ? 1.035 : 1.0) .opacity(configuration.isPressed ? 0.85 : 1.0) .shadow( color: isFocused ? DS.Shadows.cardLifted : .clear, @@ -27,9 +27,13 @@ struct TVFocusButtonStyle: ButtonStyle { y: isFocused ? DS.Shadows.cardLiftedY : 0 ) .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(DS.Colors.interactive.opacity(isFocused ? 0.5 : 0), lineWidth: 2.5) + RoundedRectangle(cornerRadius: 26, style: .continuous) + .strokeBorder(DS.Colors.interactive.opacity(isFocused ? 0.72 : 0), lineWidth: 3) ) + .overlay { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(DS.Colors.interactive.opacity(isFocused ? 0.08 : 0)) + } .animation(.easeInOut(duration: 0.2), value: isFocused) .animation(.easeOut(duration: 0.12), value: configuration.isPressed) } diff --git a/mlbTVOS/Views/Components/ScoresTickerView.swift b/mlbTVOS/Views/Components/ScoresTickerView.swift index d1b7fec..8e2ed21 100644 --- a/mlbTVOS/Views/Components/ScoresTickerView.swift +++ b/mlbTVOS/Views/Components/ScoresTickerView.swift @@ -19,10 +19,10 @@ struct ScoresTickerView: View { .padding(.vertical, 14) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(.black.opacity(0.72)) + .fill(DS.Colors.navFill) .overlay { RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(.white.opacity(0.08), lineWidth: 1) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) } ) .accessibilityHidden(true) diff --git a/mlbTVOS/Views/ContentView.swift b/mlbTVOS/Views/ContentView.swift index 723f87f..88dbbef 100644 --- a/mlbTVOS/Views/ContentView.swift +++ b/mlbTVOS/Views/ContentView.swift @@ -4,42 +4,57 @@ struct ContentView: View { @Environment(GamesViewModel.self) private var viewModel @State private var selectedSection: AppSection = .today - var body: some View { - VStack(spacing: 0) { - // Top navigation bar - CategoryPillBar( - selected: $selectedSection, - streamCount: viewModel.activeStreams.count - ) - .padding(.horizontal, DS.Spacing.edgeInset) - .padding(.vertical, navPadV) - .background(DS.Colors.background) + private var showsTicker: Bool { + selectedSection != .multiView && !viewModel.games.isEmpty + } - // Content area - Group { - switch selectedSection { - case .today: - DashboardView() - case .intel: - LeagueCenterView() - case .highlights: - FeedView() - case .multiView: - MultiStreamView() - case .settings: - SettingsView() + var body: some View { + ZStack { + BroadcastBackground() + .ignoresSafeArea() + + VStack(spacing: shellSpacing) { + CategoryPillBar( + selected: $selectedSection, + streamCount: viewModel.activeStreams.count, + totalGames: viewModel.games.count, + liveGames: viewModel.liveGames.count + ) + .padding(.horizontal, DS.Spacing.edgeInset) + .padding(.top, navPadTop) + + if showsTicker { + ScoresTickerView() + .padding(.horizontal, DS.Spacing.edgeInset) } + + Group { + switch selectedSection { + case .today: + DashboardView() + case .intel: + LeagueCenterView() + case .highlights: + FeedView() + case .multiView: + MultiStreamView() + case .settings: + SettingsView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } - .background(DS.Colors.background) .task { await viewModel.loadGames() } } #if os(tvOS) - private var navPadV: CGFloat { 20 } + private var navPadTop: CGFloat { 26 } + private var shellSpacing: CGFloat { 18 } #else - private var navPadV: CGFloat { 12 } + private var navPadTop: CGFloat { 14 } + private var shellSpacing: CGFloat { 14 } #endif } diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index f4a109b..5b7cb6b 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -84,70 +84,87 @@ struct DashboardView: View { private var shelfCardWidth: CGFloat { #if os(iOS) - horizontalSizeClass == .compact ? 340 : 480 + horizontalSizeClass == .compact ? 340 : 500 #else - 640 + 540 #endif } + private var controlRailWidth: CGFloat { + #if os(iOS) + horizontalSizeClass == .compact ? 0 : 360 + #else + 420 + #endif + } + + private var usesStackedHeroLayout: Bool { + #if os(iOS) + horizontalSizeClass == .compact + #else + false + #endif + } + + private var radarGames: [Game] { + if !viewModel.liveGames.isEmpty { + return Array(viewModel.liveGames.prefix(4)) + } + if !viewModel.scheduledGames.isEmpty { + return Array(viewModel.scheduledGames.prefix(4)) + } + return Array(viewModel.finalGames.prefix(4)) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: contentSpacing) { headerSection if viewModel.isLoading { - HStack { - Spacer() - ProgressView("Loading games...") - .font(.title3) - Spacer() - } - .padding(.top, 80) + loadingState } else if let error = viewModel.errorMessage, viewModel.games.isEmpty { - HStack { - Spacer() - VStack(spacing: 20) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 50)) - .foregroundStyle(.secondary) - Text(error) - .font(.title3) - .foregroundStyle(.secondary) - Button("Retry") { - Task { await viewModel.loadGames() } - } - } - Spacer() - } - .padding(.top, 80) + errorState(error) } else { - // Hero featured game - if let featured = viewModel.featuredGame { - FeaturedGameCard(game: featured) { - selectedGame = featured - } - } + overviewStrip + heroAndControlSection if !viewModel.liveGames.isEmpty { - gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id) + gameShelf( + title: "Live Board", + subtitle: "Open games with inning state, records, and stream availability.", + icon: "antenna.radiowaves.left.and.right", + accent: DS.Colors.live, + games: viewModel.liveGames, + excludeId: viewModel.featuredGame?.id + ) } if !viewModel.scheduledGames.isEmpty { - gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id) + gameShelf( + title: "Upcoming Windows", + subtitle: "Probables, first pitch, and watch-ready cards for the rest of the slate.", + icon: "calendar", + accent: DS.Colors.warning, + games: viewModel.scheduledGames, + excludeId: viewModel.featuredGame?.id + ) } if !viewModel.finalGames.isEmpty { - gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id) + gameShelf( + title: "Completed Games", + subtitle: "Finished scoreboards ready for replays, box scores, and highlights.", + icon: "checkmark.circle", + accent: DS.Colors.positive, + games: viewModel.finalGames, + excludeId: viewModel.featuredGame?.id + ) } } - - featuredChannelsSection - - if !viewModel.activeStreams.isEmpty { - multiViewStatus - } } .padding(.horizontal, horizontalPadding) .padding(.vertical, verticalPadding) } + .scrollIndicators(.hidden) .onAppear { logDashboard("DashboardView appeared") viewModel.startAutoRefresh() @@ -340,232 +357,632 @@ struct DashboardView: View { ) } - // MARK: - Game Shelf (Horizontal) + private var loadingState: some View { + VStack(spacing: 18) { + ProgressView() + .scaleEffect(1.3) + Text("Loading the daily board") + .font(sectionTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + Text("Scores, streams, and matchup context are on the way.") + .font(sectionBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 120) + .background(surfaceCardBackground()) + } - @ViewBuilder - private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View { - let filtered = games.filter { $0.id != excludeId } - if !filtered.isEmpty { - VStack(alignment: .leading, spacing: 20) { - Label(title, systemImage: icon) - .font(.title3.weight(.bold)) - .foregroundStyle(DS.Colors.textSecondary) + private func errorState(_ error: String) -> some View { + VStack(spacing: 18) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 44, weight: .bold)) + .foregroundStyle(DS.Colors.warning) - ScrollView(.horizontal) { - LazyHStack(spacing: 30) { - ForEach(filtered) { game in - GameCardView(game: game) { - selectedGame = game - } - .frame(width: shelfCardWidth) - } - } - .padding(.vertical, 12) - } - .scrollClipDisabled() + Text(error) + .font(sectionTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + + Button("Reload Board") { + Task { await viewModel.loadGames() } + } + .padding(.horizontal, 22) + .padding(.vertical, 14) + .background( + Capsule() + .fill(DS.Colors.interactive) + ) + .foregroundStyle(Color.black.opacity(0.82)) + .platformCardStyle() + } + .frame(maxWidth: .infinity) + .padding(.vertical, 110) + .background(surfaceCardBackground()) + } + + private var headerSection: some View { + ViewThatFits { + HStack(alignment: .bottom, spacing: 28) { + headerCopy + Spacer(minLength: 16) + dateNavigator + } + + VStack(alignment: .leading, spacing: 22) { + headerCopy + dateNavigator } } } - // MARK: - Header + private var headerCopy: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Daily Control Room") + .font(headerTitleFont) + .foregroundStyle(DS.Colors.textPrimary) - @ViewBuilder - private var headerSection: some View { - HStack(alignment: .center) { - // Date navigation — compact inline - HStack(spacing: 12) { - Button { - Task { await viewModel.goToPreviousDay() } - } label: { - Image(systemName: "chevron.left") - .font(.system(size: dateNavIconSize, weight: .semibold)) - .foregroundStyle(DS.Colors.textTertiary) - } + Text("A broadcast-grade slate view with live radar, featured watch windows, and fast access to every stream.") + .font(headerBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var dateNavigator: some View { + HStack(spacing: 12) { + navigatorButton(systemImage: "chevron.left") { + Task { await viewModel.goToPreviousDay() } + } + + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.isToday ? "Today" : "Archive Day") + .font(dateLabelFont) + .foregroundStyle(DS.Colors.textTertiary) Text(viewModel.displayDateString) .font(dateFont) .foregroundStyle(DS.Colors.textPrimary) .contentTransition(.numericText()) + } + navigatorButton(systemImage: "chevron.right") { + Task { await viewModel.goToNextDay() } + } + + if !viewModel.isToday { Button { - Task { await viewModel.goToNextDay() } + Task { await viewModel.goToToday() } } label: { - Image(systemName: "chevron.right") - .font(.system(size: dateNavIconSize, weight: .semibold)) - .foregroundStyle(DS.Colors.textTertiary) + Text("Jump to Today") + .font(todayBtnFont) + .foregroundStyle(Color.black.opacity(0.84)) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background( + Capsule() + .fill(DS.Colors.interactive) + ) } + .platformCardStyle() + } + } + .padding(.horizontal, 22) + .padding(.vertical, 18) + .background(surfaceCardBackground(radius: 26)) + } - if !viewModel.isToday { - Button { - Task { await viewModel.goToToday() } - } label: { - Text("Today") - .font(todayBtnFont) - .foregroundStyle(DS.Colors.interactive) + private func navigatorButton(systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: dateNavIconSize, weight: .bold)) + .foregroundStyle(DS.Colors.textPrimary) + .frame(width: 52, height: 52) + .background( + Circle() + .fill(DS.Colors.panelFillMuted) + ) + } + .platformCardStyle() + } + + private var overviewStrip: some View { + ViewThatFits { + HStack(spacing: 18) { + metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live) + metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning) + metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive) + metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 18) { + metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live) + metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning) + metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive) + metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media) + } + .padding(.vertical, 4) + } + .scrollClipDisabled() + } + } + + private var liveStatusDetail: String { + if viewModel.liveGames.isEmpty { + return "No live first pitch yet" + } + return "\(viewModel.liveGames.count) games active now" + } + + private var activeAudioDetail: String { + if let activeAudio = viewModel.activeAudioStream { + return "Audio: \(activeAudio.game.awayTeam.code) @ \(activeAudio.game.homeTeam.code)" + } + if isPiPActive { + return "Picture in Picture active" + } + return "Quadbox ready" + } + + private func metricTile(value: String, label: String, detail: String, systemImage: String, tint: Color) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(tint) + .frame(width: 28, height: 28) + + VStack(alignment: .leading, spacing: 6) { + Text(value) + .font(metricValueFont) + .foregroundStyle(DS.Colors.textPrimary) + .monospacedDigit() + + Text(label) + .font(metricLabelFont) + .foregroundStyle(DS.Colors.textSecondary) + + Text(detail) + .font(metricDetailFont) + .foregroundStyle(DS.Colors.textTertiary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 18) + .background(surfaceCardBackground(radius: 26)) + } + + private var heroAndControlSection: some View { + Group { + if usesStackedHeroLayout { + VStack(alignment: .leading, spacing: 22) { + featuredHero + controlRail + } + } else { + HStack(alignment: .top, spacing: 24) { + featuredHero + .frame(maxWidth: .infinity, alignment: .leading) + controlRail + .frame(width: controlRailWidth, alignment: .leading) + } + } + } + } + + private var featuredHero: some View { + Group { + if let featured = viewModel.featuredGame { + FeaturedGameCard(game: featured) { + selectedGame = featured + } + } else { + VStack(alignment: .leading, spacing: 16) { + Text("No featured matchup") + .font(DS.Fonts.sectionTitle) + .foregroundStyle(DS.Colors.textPrimary) + Text("As soon as the slate populates, the best watch window appears here with scores, context, and stream access.") + .font(DS.Fonts.body) + .foregroundStyle(DS.Colors.textSecondary) + } + .frame(maxWidth: .infinity, minHeight: 320, alignment: .leading) + .padding(32) + .background(surfaceCardBackground(radius: 34)) + } + } + } + + private var controlRail: some View { + VStack(alignment: .leading, spacing: 18) { + liveRadarPanel + featuredChannelsSection + multiViewStatus + } + } + + private var liveRadarPanel: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text("Live Radar") + .font(railTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + + Text(radarSubtitle) + .font(railBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + + if radarGames.isEmpty { + Text("No games loaded yet.") + .font(railBodyFont) + .foregroundStyle(DS.Colors.textTertiary) + } else { + VStack(spacing: 12) { + ForEach(radarGames) { game in + radarRow(game) } } } + } + .padding(24) + .background(surfaceCardBackground()) + } - Spacer() + private var radarSubtitle: String { + if !viewModel.liveGames.isEmpty { + return "Fast board for the most active windows." + } + if !viewModel.scheduledGames.isEmpty { + return "No live action yet. Upcoming first pitches are next." + } + return "Completed slate snapshots." + } - HStack(spacing: 12) { - Text("\(viewModel.games.count) games") - .font(metaCountFont) - .foregroundStyle(DS.Colors.textTertiary) + private func radarRow(_ game: Game) -> some View { + Button { + selectedGame = game + } label: { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text("\(game.awayTeam.code) @ \(game.homeTeam.code)") + .font(radarRowTitleFont) + .foregroundStyle(DS.Colors.textPrimary) - if !viewModel.liveGames.isEmpty { - HStack(spacing: 5) { - Circle().fill(DS.Colors.live).frame(width: 7, height: 7) - Text("\(viewModel.liveGames.count) live") - .font(metaCountFont) - .foregroundStyle(DS.Colors.live) + Text(radarDetail(for: game)) + .font(radarRowBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + .lineLimit(2) + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 6) { + Text(radarStatus(for: game)) + .font(radarRowStatusFont) + .foregroundStyle(radarTint(for: game)) + + if let score = game.scoreDisplay { + Text(score) + .font(radarRowScoreFont) + .foregroundStyle(DS.Colors.textPrimary) + .monospacedDigit() } } } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(DS.Colors.panelFillMuted) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } + ) + } + .platformCardStyle() + } + + private func radarDetail(for game: Game) -> String { + if game.isLive { + return game.venue ?? "Live board ready" + } + if let pitchers = game.pitchers, !pitchers.isEmpty { + return pitchers + } + return game.venue ?? "Open matchup details" + } + + private func radarStatus(for game: Game) -> String { + switch game.status { + case .live(let inning): + return inning?.uppercased() ?? "LIVE" + case .scheduled(let time): + return time.uppercased() + case .final_: + return "FINAL" + case .unknown: + return "PENDING" + } + } + + private func radarTint(for game: Game) -> Color { + if game.isLive { return DS.Colors.live } + if game.isFinal { return DS.Colors.positive } + return DS.Colors.warning + } + + private func gameShelf( + title: String, + subtitle: String, + icon: String, + accent: Color, + games: [Game], + excludeId: String? + ) -> some View { + let filtered = games.filter { $0.id != excludeId } + return Group { + if !filtered.isEmpty { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .bottom, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Label(title, systemImage: icon) + .font(sectionTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + + Text(subtitle) + .font(sectionBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + } + + Spacer() + + Circle() + .fill(accent) + .frame(width: 10, height: 10) + } + + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 24) { + ForEach(filtered) { game in + GameCardView(game: game) { + selectedGame = game + } + .frame(width: shelfCardWidth) + } + } + .padding(.vertical, 8) + } + .scrollClipDisabled() + } + } } } #if os(tvOS) - private var dateFont: Font { .system(size: 32, weight: .bold) } - private var dateNavIconSize: CGFloat { 22 } - private var todayBtnFont: Font { .system(size: 22, weight: .bold, design: .rounded) } - private var metaCountFont: Font { .system(size: 22, weight: .medium) } + private var headerTitleFont: Font { .system(size: 50, weight: .black, design: .rounded) } + private var headerBodyFont: Font { .system(size: 22, weight: .medium) } + private var dateLabelFont: Font { .system(size: 14, weight: .black, design: .rounded) } + private var dateFont: Font { .system(size: 26, weight: .bold, design: .rounded) } + private var dateNavIconSize: CGFloat { 20 } + private var todayBtnFont: Font { .system(size: 18, weight: .black, design: .rounded) } + private var metricValueFont: Font { .system(size: 34, weight: .black, design: .rounded) } + private var metricLabelFont: Font { .system(size: 19, weight: .bold, design: .rounded) } + private var metricDetailFont: Font { .system(size: 16, weight: .semibold) } + private var railTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) } + private var railBodyFont: Font { .system(size: 17, weight: .medium) } + private var radarRowTitleFont: Font { .system(size: 19, weight: .black, design: .rounded) } + private var radarRowBodyFont: Font { .system(size: 15, weight: .semibold) } + private var radarRowStatusFont: Font { .system(size: 14, weight: .black, design: .rounded) } + private var radarRowScoreFont: Font { .system(size: 20, weight: .black, design: .rounded).monospacedDigit() } + private var sectionTitleFont: Font { .system(size: 30, weight: .black, design: .rounded) } + private var sectionBodyFont: Font { .system(size: 17, weight: .medium) } #else - private var dateFont: Font { .system(size: 22, weight: .bold) } - private var dateNavIconSize: CGFloat { 16 } - private var todayBtnFont: Font { .system(size: 15, weight: .bold, design: .rounded) } - private var metaCountFont: Font { .system(size: 14, weight: .medium) } + private var headerTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) } + private var headerBodyFont: Font { .system(size: 15, weight: .medium) } + private var dateLabelFont: Font { .system(size: 10, weight: .black, design: .rounded) } + private var dateFont: Font { .system(size: 17, weight: .bold, design: .rounded) } + private var dateNavIconSize: CGFloat { 15 } + private var todayBtnFont: Font { .system(size: 13, weight: .black, design: .rounded) } + private var metricValueFont: Font { .system(size: 22, weight: .black, design: .rounded) } + private var metricLabelFont: Font { .system(size: 13, weight: .bold, design: .rounded) } + private var metricDetailFont: Font { .system(size: 11, weight: .semibold) } + private var railTitleFont: Font { .system(size: 20, weight: .black, design: .rounded) } + private var railBodyFont: Font { .system(size: 12, weight: .medium) } + private var radarRowTitleFont: Font { .system(size: 14, weight: .black, design: .rounded) } + private var radarRowBodyFont: Font { .system(size: 11, weight: .semibold) } + private var radarRowStatusFont: Font { .system(size: 10, weight: .black, design: .rounded) } + private var radarRowScoreFont: Font { .system(size: 14, weight: .black, design: .rounded).monospacedDigit() } + private var sectionTitleFont: Font { .system(size: 22, weight: .black, design: .rounded) } + private var sectionBodyFont: Font { .system(size: 12, weight: .medium) } #endif - // MARK: - Featured Channels - - @ViewBuilder private var featuredChannelsSection: some View { - ViewThatFits { - HStack(alignment: .top, spacing: 24) { - mlbNetworkCard - .frame(maxWidth: .infinity) + VStack(alignment: .leading, spacing: 14) { + Text("Quick Channels") + .font(railTitleFont) + .foregroundStyle(DS.Colors.textPrimary) - nsfwVideosCard - .frame(maxWidth: .infinity) - } - - VStack(alignment: .leading, spacing: 16) { - mlbNetworkCard - nsfwVideosCard - } + mlbNetworkCard + nsfwVideosCard } + .padding(24) + .background(surfaceCardBackground()) } - @ViewBuilder private var mlbNetworkCard: some View { let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" }) - Button { + return channelCard( + title: "MLB Network", + subtitle: "League-wide coverage, whip-around cuts, analysis, and highlights.", + systemImage: "tv.fill", + tint: .blue, + status: added ? "Pinned to Multi-View" : "Open Channel" + ) { showMLBNetworkSheet = true - } label: { - HStack(spacing: 16) { - Image(systemName: "tv.fill") - .font(.title2) - .foregroundStyle(.blue) - .frame(width: 56, height: 56) - .background(.blue.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - - VStack(alignment: .leading, spacing: 4) { - Text("MLB Network") - .font(.title3.weight(.bold)) - .foregroundStyle(DS.Colors.textPrimary) - Text("Live coverage, analysis & highlights") - .font(.subheadline) - .foregroundStyle(DS.Colors.textSecondary) - } - - Spacer() - - if added { - Label("In Multi-View", systemImage: "checkmark.circle.fill") - .font(.subheadline.weight(.bold)) - .foregroundStyle(DS.Colors.positive) - } - } - .frame(maxWidth: .infinity, minHeight: 108, alignment: .leading) - .padding(24) - .background(DS.Colors.panelFill) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) } - .platformCardStyle() } - @ViewBuilder private var nsfwVideosCard: some View { let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID }) - Button { + return channelCard( + title: SpecialPlaybackChannelConfig.werkoutNSFWTitle, + subtitle: SpecialPlaybackChannelConfig.werkoutNSFWSubtitle, + systemImage: "play.rectangle.fill", + tint: .pink, + status: added ? "Pinned to Multi-View" : "Private feed access" + ) { showWerkoutNSFWSheet = true - } label: { - HStack(spacing: 16) { - Image(systemName: "play.rectangle.fill") - .font(.title2) - .foregroundStyle(.pink) - .frame(width: 56, height: 56) - .background(.pink.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } - VStack(alignment: .leading, spacing: 4) { - Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle) - .font(.title3.weight(.bold)) - .foregroundStyle(DS.Colors.textPrimary) - Text(SpecialPlaybackChannelConfig.werkoutNSFWSubtitle) - .font(.subheadline) - .foregroundStyle(DS.Colors.textSecondary) - } + private var multiViewStatus: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Multi-View Status") + .font(railTitleFont) + .foregroundStyle(DS.Colors.textPrimary) - Spacer() + Text("Current grid state, active audio focus, and ready-to-open tiles.") + .font(railBodyFont) + .foregroundStyle(DS.Colors.textSecondary) - if added { - Label("In Multi-View", systemImage: "checkmark.circle.fill") - .font(.subheadline.weight(.bold)) - .foregroundStyle(DS.Colors.positive) - } else { - Label("Open", systemImage: "play.fill") - .font(.subheadline.weight(.bold)) - .foregroundStyle(.pink) + HStack(spacing: 14) { + metricBadge(value: "\(viewModel.activeStreams.count)/4", label: "Tiles") + metricBadge(value: viewModel.multiViewLayoutMode.title, label: "Layout") + metricBadge(value: viewModel.activeAudioStream == nil ? "Muted" : "Live", label: "Audio") + } + + if viewModel.activeStreams.isEmpty { + Text("No active tiles yet. Add any game feed to build the quadbox.") + .font(railBodyFont) + .foregroundStyle(DS.Colors.textTertiary) + } else { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + ForEach(viewModel.activeStreams) { stream in + HStack(spacing: 10) { + Circle() + .fill(viewModel.activeAudioStream?.id == stream.id ? DS.Colors.interactive : DS.Colors.positive) + .frame(width: 10, height: 10) + Text(stream.label) + .font(radarRowBodyFont) + .foregroundStyle(DS.Colors.textPrimary) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(DS.Colors.panelFillMuted) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } + ) + } } } - .frame(maxWidth: .infinity, minHeight: 108, alignment: .leading) - .padding(24) - .background(DS.Colors.panelFill) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) + } + .padding(24) + .background(surfaceCardBackground()) + } + + private func channelCard( + title: String, + subtitle: String, + systemImage: String, + tint: Color, + status: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 16) { + Image(systemName: systemImage) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(tint) + .frame(width: 58, height: 58) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(tint.opacity(0.16)) + ) + + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(radarRowTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + + Text(subtitle) + .font(radarRowBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + .lineLimit(2) + } + + Spacer(minLength: 8) + + Text(status) + .font(radarRowStatusFont) + .foregroundStyle(tint) + .multilineTextAlignment(.trailing) + .lineLimit(2) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(DS.Colors.panelFillMuted) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } + ) } .platformCardStyle() } - // MARK: - Multi-View Status - - @ViewBuilder - private var multiViewStatus: some View { - VStack(alignment: .leading, spacing: 14) { - Label("Multi-View", systemImage: "rectangle.split.2x2") - .font(.title3.weight(.bold)) - .foregroundStyle(DS.Colors.textSecondary) - - HStack(spacing: 12) { - ForEach(viewModel.activeStreams) { stream in - HStack(spacing: 8) { - Circle().fill(DS.Colors.positive).frame(width: 8, height: 8) - Text(stream.label) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(DS.Colors.textPrimary) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(DS.Colors.panelFill) - .clipShape(Capsule()) - .shadow(color: DS.Shadows.card, radius: 8, y: 2) - } - } + private func metricBadge(value: String, label: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(value) + .font(radarRowTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + Text(label) + .font(radarRowBodyFont) + .foregroundStyle(DS.Colors.textTertiary) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(DS.Colors.panelFillMuted) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } + ) + } + + private func surfaceCardBackground(radius: CGFloat = 28) -> some View { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill( + LinearGradient( + colors: [ + DS.Colors.panelFill, + DS.Colors.panelFillMuted, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } + .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) } } diff --git a/mlbTVOS/Views/FeaturedGameCard.swift b/mlbTVOS/Views/FeaturedGameCard.swift index 80e91a3..57e2593 100644 --- a/mlbTVOS/Views/FeaturedGameCard.swift +++ b/mlbTVOS/Views/FeaturedGameCard.swift @@ -10,22 +10,21 @@ struct FeaturedGameCard: View { private var awayPitcherName: String? { game.pitchers?.components(separatedBy: " vs ").first } + private var homePitcherName: String? { let parts = game.pitchers?.components(separatedBy: " vs ") ?? [] return parts.count > 1 ? parts.last : nil } private var heroImageURL: URL? { - // Prefer pitcher action hero photo — big, dramatic, like a cast photo if let pitcherId = game.homePitcherId { - return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_1200,q_auto:best/v1/people/\(pitcherId)/action/hero/current") + return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_1400,q_auto:best/v1/people/\(pitcherId)/action/hero/current") } if let pitcherId = game.awayPitcherId { - return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_1200,q_auto:best/v1/people/\(pitcherId)/action/hero/current") + return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_1400,q_auto:best/v1/people/\(pitcherId)/action/hero/current") } - // Fall back to large team logo if let teamId = game.homeTeam.teamId { - return URL(string: "https://midfield.mlbstatic.com/v1/team/\(teamId)/spots/800") + return URL(string: "https://midfield.mlbstatic.com/v1/team/\(teamId)/spots/1200") } return nil } @@ -33,116 +32,214 @@ struct FeaturedGameCard: View { var body: some View { Button(action: onSelect) { ZStack(alignment: .topLeading) { - // White/cream base - DS.Colors.panelFill + backgroundLayer - // Stadium image on the right side, fading into white on the left - HStack(spacing: 0) { - Spacer() - ZStack(alignment: .leading) { - heroImage - .frame(width: imageWidth) - - // White fade from left edge of image - LinearGradient( - colors: [ - DS.Colors.panelFill, - DS.Colors.panelFill.opacity(0.8), - DS.Colors.panelFill.opacity(0.3), - .clear - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: fadeWidth) - } - } - - // Text content on the left VStack(alignment: .leading, spacing: contentSpacing) { - // Status badge - statusBadge + headerRow - // Giant matchup title — thin "away" bold "home" - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 0) { - Text(game.awayTeam.displayName) - .font(titleThinFont) - .foregroundStyle(DS.Colors.textSecondary) - Text(" vs ") - .font(titleThinFont) - .foregroundStyle(DS.Colors.textTertiary) + HStack(alignment: .bottom, spacing: 28) { + VStack(alignment: .leading, spacing: 16) { + scoreboardRow(team: game.awayTeam, isLeading: isWinning(away: true)) + scoreboardRow(team: game.homeTeam, isLeading: isWinning(away: false)) } - Text(game.homeTeam.displayName) - .font(titleBoldFont) - .foregroundStyle(DS.Colors.interactive) + .frame(maxWidth: .infinity, alignment: .leading) + + detailPanel + .frame(width: detailPanelWidth, alignment: .trailing) } - // Metadata line - metadataLine - - // Live score or description - if game.isLive { - liveSection - } else if game.isFinal { - finalSection - } else { - scheduledSection - } - - // CTA buttons - HStack(spacing: 14) { - if game.hasStreams { - Label("Watch Now", systemImage: "play.fill") - .font(ctaFont) - .foregroundStyle(DS.Colors.interactive) - .padding(.horizontal, ctaPadH) - .padding(.vertical, ctaPadV) - .overlay( - Capsule().strokeBorder(DS.Colors.interactive, lineWidth: 2) - ) - } - - Image(systemName: "plus") - .font(ctaFont) - .foregroundStyle(DS.Colors.textTertiary) - .padding(ctaPadV) - .overlay( - Circle().strokeBorder(DS.Colors.textQuaternary, lineWidth: 1.5) - ) - } + insightStrip } .padding(.horizontal, heroPadH) .padding(.vertical, heroPadV) - .frame(maxWidth: textAreaWidth, alignment: .topLeading) } + .frame(maxWidth: .infinity) .frame(height: heroHeight) .clipShape(RoundedRectangle(cornerRadius: heroRadius, style: .continuous)) - .shadow(color: .black.opacity(0.08), radius: 30, y: 12) + .overlay( + RoundedRectangle(cornerRadius: heroRadius, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.32), radius: 36, y: 18) } .platformCardStyle() } - // MARK: - Live Section + private var backgroundLayer: some View { + ZStack { + RoundedRectangle(cornerRadius: heroRadius, style: .continuous) + .fill( + LinearGradient( + colors: [ + DS.Colors.panelFill, + DS.Colors.backgroundElevated, + Color.black.opacity(0.94), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) - @ViewBuilder - private var liveSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline, spacing: 16) { - if let away = game.awayTeam.score, let home = game.homeTeam.score { - Text("\(away) - \(home)") - .font(scoreFont) - .foregroundStyle(DS.Colors.textPrimary) - .monospacedDigit() - .contentTransition(.numericText()) + // Team color wash — gradient instead of blur for performance + LinearGradient( + colors: [ + awayColor.opacity(0.2), + .clear, + homeColor.opacity(0.18), + ], + startPoint: .leading, + endPoint: .trailing + ) + + heroImage + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay { + LinearGradient( + colors: [ + Color.black.opacity(0.85), + Color.black.opacity(0.48), + Color.black.opacity(0.22), + ], + startPoint: .leading, + endPoint: .trailing + ) } - if let inning = game.currentInningDisplay { - Text(inning) - .font(inningFont) - .foregroundStyle(DS.Colors.live) + LinearGradient( + colors: [ + Color.black.opacity(0.16), + Color.black.opacity(0.52), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + + private var headerRow: some View { + HStack(alignment: .top, spacing: 20) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + statusBadge + + if let gameType = game.gameType, !gameType.isEmpty { + metaBadge(gameType.uppercased(), tint: DS.Colors.media) + } + + if game.hasStreams { + metaBadge("\(game.broadcasts.count) FEED\(game.broadcasts.count == 1 ? "" : "S")", tint: DS.Colors.interactive) + } + } + + Text("Featured Matchup") + .font(labelFont) + .foregroundStyle(DS.Colors.textTertiary) + .tracking(1.8) + + Text(game.displayTitle) + .font(titleFont) + .foregroundStyle(DS.Colors.onDarkPrimary) + .lineLimit(2) + } + + Spacer(minLength: 16) + + HStack(spacing: 12) { + if let venue = game.venue { + summaryTag(value: venue, systemImage: "mappin.and.ellipse") + } + + if game.isBlackedOut { + summaryTag(value: "Blackout", systemImage: "eye.slash.fill") + } else if game.hasStreams { + summaryTag(value: "Watch Now", systemImage: "play.fill") } } + } + } + + private func scoreboardRow(team: TeamInfo, isLeading: Bool) -> some View { + HStack(spacing: 16) { + TeamLogoView(team: team, size: logoSize) + + VStack(alignment: .leading, spacing: 5) { + Text(team.code) + .font(codeFont) + .foregroundStyle(.white) + + Text(team.displayName) + .font(nameFont) + .foregroundStyle(DS.Colors.onDarkSecondary) + + HStack(spacing: 10) { + if let record = team.record { + Text(record) + .font(metadataFont) + .foregroundStyle(DS.Colors.onDarkSecondary) + } + + if let summary = team.standingSummary { + Text(summary) + .font(metadataFont) + .foregroundStyle(DS.Colors.onDarkTertiary) + .lineLimit(1) + } + } + } + + Spacer(minLength: 8) + + Text(team.score.map(String.init) ?? "—") + .font(scoreFont) + .foregroundStyle(isLeading ? .white : DS.Colors.onDarkSecondary) + .monospacedDigit() + } + .padding(.horizontal, rowPadH) + .padding(.vertical, rowPadV) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.black.opacity(isLeading ? 0.34 : 0.22)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(Color.white.opacity(isLeading ? 0.10 : 0.06), lineWidth: 1) + } + ) + } + + @ViewBuilder + private var detailPanel: some View { + VStack(alignment: .leading, spacing: 16) { + switch game.status { + case .live: + livePanel + case .final_: + finalPanel + case .scheduled: + scheduledPanel + case .unknown: + statusFallbackPanel + } + } + .padding(detailPanelPad) + .background( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(Color.black.opacity(0.34)) + .overlay { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1) + } + ) + } + + private var livePanel: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Live Situation") + .font(panelLabelFont) + .foregroundStyle(DS.Colors.textTertiary) + + Text(game.currentInningDisplay ?? "Live") + .font(panelValueFont) + .foregroundStyle(.white) if let linescore = game.linescore { DiamondView( @@ -150,93 +247,172 @@ struct FeaturedGameCard: View { strikes: linescore.strikes ?? 0, outs: linescore.outs ?? 0 ) + + if let awayRuns = linescore.teams?.away?.runs, + let homeRuns = linescore.teams?.home?.runs, + let awayHits = linescore.teams?.away?.hits, + let homeHits = linescore.teams?.home?.hits { + HStack(spacing: 14) { + detailMetric(label: game.awayTeam.code, value: "\(awayRuns)R / \(awayHits)H") + detailMetric(label: game.homeTeam.code, value: "\(homeRuns)R / \(homeHits)H") + } + } } } } - // MARK: - Final Section + private var finalPanel: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Final") + .font(panelLabelFont) + .foregroundStyle(DS.Colors.textTertiary) - @ViewBuilder - private var finalSection: some View { - if let away = game.awayTeam.score, let home = game.homeTeam.score { - HStack(spacing: 12) { - Text("\(away) - \(home)") - .font(scoreFont) - .foregroundStyle(DS.Colors.textPrimary) - .monospacedDigit() - Text("FINAL") - .font(inningFont) - .foregroundStyle(DS.Colors.textTertiary) + Text(game.scoreDisplay ?? "Complete") + .font(panelValueFont) + .foregroundStyle(.white) + + Text("Box score, play timeline, and highlights are ready in Game Center.") + .font(panelBodyFont) + .foregroundStyle(DS.Colors.onDarkSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var scheduledPanel: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Starting Pitchers") + .font(panelLabelFont) + .foregroundStyle(DS.Colors.textTertiary) + + Text(pitcherMatchupText) + .font(panelValueFont) + .foregroundStyle(.white) + .lineLimit(3) + + if let startTime = game.startTime { + Text("First pitch \(startTime)") + .font(panelBodyFont) + .foregroundStyle(DS.Colors.onDarkSecondary) } } } - // MARK: - Scheduled Section + private var statusFallbackPanel: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Game State") + .font(panelLabelFont) + .foregroundStyle(DS.Colors.textTertiary) - @ViewBuilder - private var scheduledSection: some View { + Text(game.status.label.isEmpty ? "Awaiting update" : game.status.label) + .font(panelValueFont) + .foregroundStyle(.white) + } + } + + private var insightStrip: some View { + HStack(spacing: 14) { + insightCard( + title: "Pitching", + value: pitcherInsightText, + accent: DS.Colors.media + ) + + insightCard( + title: "Venue", + value: game.venue ?? "TBD", + accent: DS.Colors.interactive + ) + + insightCard( + title: "Feeds", + value: game.isBlackedOut ? "Blackout" : "\(game.broadcasts.count) available", + accent: game.isBlackedOut ? DS.Colors.live : DS.Colors.positive + ) + } + } + + private func insightCard(title: String, value: String, accent: Color) -> some View { VStack(alignment: .leading, spacing: 6) { - if let pitchers = game.pitchers { - Text(pitchers) - .font(descFont) - .foregroundStyle(DS.Colors.textSecondary) - .lineLimit(2) - } + Text(title) + .font(insightTitleFont) + .foregroundStyle(DS.Colors.textTertiary) + + Text(value) + .font(insightValueFont) + .foregroundStyle(.white) + .lineLimit(2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(accent.opacity(0.14)) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(accent.opacity(0.20), lineWidth: 1) + } + ) + } + + private func detailMetric(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(insightTitleFont) + .foregroundStyle(DS.Colors.textTertiary) + + Text(value) + .font(insightValueFont) + .foregroundStyle(.white) } } - // MARK: - Status Badge + private func summaryTag(value: String, systemImage: String) -> some View { + Label(value, systemImage: systemImage) + .font(summaryFont) + .foregroundStyle(.white.opacity(0.92)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Capsule() + .fill(Color.black.opacity(0.28)) + .overlay { + Capsule() + .strokeBorder(Color.white.opacity(0.08), lineWidth: 1) + } + ) + } + + private func metaBadge(_ value: String, tint: Color) -> some View { + Text(value) + .font(badgeFont) + .foregroundStyle(tint) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background( + Capsule() + .fill(tint.opacity(0.14)) + .overlay { + Capsule() + .strokeBorder(tint.opacity(0.22), lineWidth: 1) + } + ) + } @ViewBuilder private var statusBadge: some View { switch game.status { case .live(let inning): - HStack(spacing: 6) { - Circle().fill(DS.Colors.live).frame(width: 8, height: 8) - Text(inning ?? "LIVE") - .font(badgeFont) - .foregroundStyle(DS.Colors.live) - } - + metaBadge(inning?.uppercased() ?? "LIVE", tint: DS.Colors.live) case .scheduled(let time): - Text(time) - .font(badgeFont) - .foregroundStyle(DS.Colors.textTertiary) - + metaBadge(time.uppercased(), tint: DS.Colors.warning) case .final_: - Text("FINAL") - .font(badgeFont) - .foregroundStyle(DS.Colors.textTertiary) - + metaBadge("FINAL", tint: DS.Colors.positive) case .unknown: - EmptyView() + metaBadge("PENDING", tint: DS.Colors.textTertiary) } } - // MARK: - Metadata Line - - @ViewBuilder - private var metadataLine: some View { - HStack(spacing: metaSeparatorWidth) { - if let venue = game.venue { - Text(venue) - } - if let record = game.awayTeam.record { - Text("\(game.awayTeam.code) \(record)") - } - if let record = game.homeTeam.record { - Text("\(game.homeTeam.code) \(record)") - } - if !game.broadcasts.isEmpty { - Text("\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")") - } - } - .font(metaFont) - .foregroundStyle(DS.Colors.textTertiary) - } - - // MARK: - Stadium Image - @ViewBuilder private var heroImage: some View { if let url = heroImageURL { @@ -246,8 +422,6 @@ struct FeaturedGameCard: View { image .resizable() .aspectRatio(contentMode: .fill) - .frame(height: heroHeight) - .clipped() default: fallbackImage } @@ -257,79 +431,88 @@ struct FeaturedGameCard: View { } } - @ViewBuilder private var fallbackImage: some View { - ZStack { - // Rich team color gradient - LinearGradient( - colors: [ - awayColor.opacity(0.4), - homeColor.opacity(0.6), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - - // Large prominent team logos - HStack(spacing: fallbackLogoGap) { - TeamLogoView(team: game.awayTeam, size: fallbackLogoSize) - .shadow(color: .black.opacity(0.2), radius: 12, y: 4) - Text("vs") - .font(.system(size: fallbackLogoSize * 0.3, weight: .light)) - .foregroundStyle(.white.opacity(0.5)) - TeamLogoView(team: game.homeTeam, size: fallbackLogoSize) - .shadow(color: .black.opacity(0.2), radius: 12, y: 4) - } - } + LinearGradient( + colors: [ + awayColor.opacity(0.32), + homeColor.opacity(0.28), + Color.clear, + ], + startPoint: .leading, + endPoint: .trailing + ) } - // MARK: - Platform Sizing + private var pitcherMatchupText: String { + if let awayPitcherName, let homePitcherName { + return "\(awayPitcherName)\nvs \(homePitcherName)" + } + return game.pitchers ?? "Pitchers pending" + } + + private var pitcherInsightText: String { + if let awayPitcherName, let homePitcherName { + return "\(awayPitcherName) vs \(homePitcherName)" + } + return game.pitchers ?? "Awaiting starters" + } + + private func isWinning(away: Bool) -> Bool { + guard let awayScore = game.awayTeam.score, let homeScore = game.homeTeam.score else { + return false + } + return away ? awayScore > homeScore : homeScore > awayScore + } #if os(tvOS) - private var heroHeight: CGFloat { 480 } - private var heroRadius: CGFloat { 28 } - private var heroPadH: CGFloat { 60 } - private var heroPadV: CGFloat { 50 } - private var contentSpacing: CGFloat { 16 } - private var imageWidth: CGFloat { 900 } - private var fadeWidth: CGFloat { 400 } - private var textAreaWidth: CGFloat { 700 } - private var metaSeparatorWidth: CGFloat { 18 } - private var fallbackLogoSize: CGFloat { 120 } - private var fallbackLogoGap: CGFloat { 40 } - - private var titleThinFont: Font { .system(size: 48, weight: .light) } - private var titleBoldFont: Font { .system(size: 52, weight: .black, design: .rounded) } - private var scoreFont: Font { .system(size: 64, weight: .black, design: .rounded) } - private var inningFont: Font { .system(size: 28, weight: .bold, design: .rounded) } - private var badgeFont: Font { .system(size: 22, weight: .bold, design: .rounded) } - private var metaFont: Font { .system(size: 22, weight: .medium) } - private var descFont: Font { .system(size: 24, weight: .medium) } - private var ctaFont: Font { .system(size: 24, weight: .bold) } - private var ctaPadH: CGFloat { 32 } - private var ctaPadV: CGFloat { 14 } + private var heroHeight: CGFloat { 470 } + private var heroRadius: CGFloat { 34 } + private var heroPadH: CGFloat { 36 } + private var heroPadV: CGFloat { 34 } + private var detailPanelWidth: CGFloat { 360 } + private var detailPanelPad: CGFloat { 26 } + private var detailPanelWidthCompact: CGFloat { 320 } + private var contentSpacing: CGFloat { 26 } + private var logoSize: CGFloat { 56 } + private var rowPadH: CGFloat { 22 } + private var rowPadV: CGFloat { 18 } + private var titleFont: Font { .system(size: 52, weight: .black, design: .rounded) } + private var labelFont: Font { .system(size: 15, weight: .black, design: .rounded) } + private var codeFont: Font { .system(size: 22, weight: .black, design: .rounded) } + private var nameFont: Font { .system(size: 28, weight: .bold, design: .rounded) } + private var metadataFont: Font { .system(size: 18, weight: .bold, design: .rounded) } + private var scoreFont: Font { .system(size: 60, weight: .black, design: .rounded).monospacedDigit() } + private var badgeFont: Font { .system(size: 13, weight: .black, design: .rounded) } + private var summaryFont: Font { .system(size: 16, weight: .bold, design: .rounded) } + private var panelLabelFont: Font { .system(size: 14, weight: .black, design: .rounded) } + private var panelValueFont: Font { .system(size: 28, weight: .black, design: .rounded) } + private var panelBodyFont: Font { .system(size: 18, weight: .semibold) } + private var insightTitleFont: Font { .system(size: 13, weight: .black, design: .rounded) } + private var insightValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) } #else private var heroHeight: CGFloat { 340 } - private var heroRadius: CGFloat { 22 } - private var heroPadH: CGFloat { 28 } - private var heroPadV: CGFloat { 28 } - private var contentSpacing: CGFloat { 10 } - private var imageWidth: CGFloat { 400 } - private var fadeWidth: CGFloat { 200 } - private var textAreaWidth: CGFloat { 350 } - private var metaSeparatorWidth: CGFloat { 12 } - private var fallbackLogoSize: CGFloat { 60 } - private var fallbackLogoGap: CGFloat { 20 } - - private var titleThinFont: Font { .system(size: 28, weight: .light) } - private var titleBoldFont: Font { .system(size: 32, weight: .black, design: .rounded) } - private var scoreFont: Font { .system(size: 40, weight: .black, design: .rounded) } - private var inningFont: Font { .system(size: 18, weight: .bold, design: .rounded) } - private var badgeFont: Font { .system(size: 14, weight: .bold, design: .rounded) } - private var metaFont: Font { .system(size: 14, weight: .medium) } - private var descFont: Font { .system(size: 15, weight: .medium) } - private var ctaFont: Font { .system(size: 16, weight: .bold) } - private var ctaPadH: CGFloat { 22 } - private var ctaPadV: CGFloat { 10 } + private var heroRadius: CGFloat { 26 } + private var heroPadH: CGFloat { 22 } + private var heroPadV: CGFloat { 22 } + private var detailPanelWidth: CGFloat { 250 } + private var detailPanelPad: CGFloat { 18 } + private var detailPanelWidthCompact: CGFloat { 240 } + private var contentSpacing: CGFloat { 18 } + private var logoSize: CGFloat { 36 } + private var rowPadH: CGFloat { 14 } + private var rowPadV: CGFloat { 12 } + private var titleFont: Font { .system(size: 30, weight: .black, design: .rounded) } + private var labelFont: Font { .system(size: 11, weight: .black, design: .rounded) } + private var codeFont: Font { .system(size: 15, weight: .black, design: .rounded) } + private var nameFont: Font { .system(size: 18, weight: .bold, design: .rounded) } + private var metadataFont: Font { .system(size: 12, weight: .bold, design: .rounded) } + private var scoreFont: Font { .system(size: 32, weight: .black, design: .rounded).monospacedDigit() } + private var badgeFont: Font { .system(size: 10, weight: .black, design: .rounded) } + private var summaryFont: Font { .system(size: 11, weight: .bold, design: .rounded) } + private var panelLabelFont: Font { .system(size: 10, weight: .black, design: .rounded) } + private var panelValueFont: Font { .system(size: 18, weight: .black, design: .rounded) } + private var panelBodyFont: Font { .system(size: 13, weight: .semibold) } + private var insightTitleFont: Font { .system(size: 10, weight: .black, design: .rounded) } + private var insightValueFont: Font { .system(size: 12, weight: .bold, design: .rounded) } #endif } diff --git a/mlbTVOS/Views/FeedView.swift b/mlbTVOS/Views/FeedView.swift index 3f0b12c..3cfcd83 100644 --- a/mlbTVOS/Views/FeedView.swift +++ b/mlbTVOS/Views/FeedView.swift @@ -23,6 +23,9 @@ struct FeedView: View { .font(DS.Fonts.sectionTitle) #endif .foregroundStyle(DS.Colors.textPrimary) + Text("Condensed games, key plays, and fresh clips from the active slate.") + .font(DS.Fonts.body) + .foregroundStyle(DS.Colors.textSecondary) } Spacer() @@ -32,11 +35,13 @@ struct FeedView: View { } } + overviewChips + if viewModel.highlights.isEmpty && !viewModel.isLoading { emptyState } else { LazyVStack(spacing: DS.Spacing.cardGap) { - ForEach(viewModel.highlights) { item in + ForEach(viewModel.highlights.prefix(50)) { item in highlightCard(item) } } @@ -67,6 +72,52 @@ struct FeedView: View { } } + private var overviewChips: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + feedChip( + title: "\(viewModel.highlights.count)", + label: "Clips", + tint: DS.Colors.media + ) + feedChip( + title: "\(viewModel.highlights.filter(\.isCondensedGame).count)", + label: "Condensed", + tint: DS.Colors.interactive + ) + feedChip( + title: "\(gamesViewModel.liveGames.count)", + label: "Live Games", + tint: DS.Colors.live + ) + } + } + .scrollClipDisabled() + } + + private func feedChip(title: String, label: String, tint: Color) -> some View { + HStack(spacing: 10) { + Text(title) + .font(chipValueFont) + .foregroundStyle(DS.Colors.textPrimary) + .monospacedDigit() + + Text(label) + .font(chipLabelFont) + .foregroundStyle(tint) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + Capsule() + .fill(DS.Colors.panelFillMuted) + .overlay { + Capsule() + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } + ) + } + @ViewBuilder private func highlightCard(_ item: HighlightItem) -> some View { Button { @@ -191,6 +242,8 @@ struct FeedView: View { 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) } + private var chipValueFont: Font { .system(size: 20, weight: .black, design: .rounded) } + private var chipLabelFont: Font { .system(size: 14, weight: .bold, design: .rounded) } #else private var edgeInset: CGFloat { 20 } private var thumbnailWidth: CGFloat { 180 } @@ -202,5 +255,7 @@ struct FeedView: View { 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) } + private var chipValueFont: Font { .system(size: 14, weight: .black, design: .rounded) } + private var chipLabelFont: Font { .system(size: 10, weight: .bold, design: .rounded) } #endif } diff --git a/mlbTVOS/Views/GameCardView.swift b/mlbTVOS/Views/GameCardView.swift index 0d0ecc2..a956dae 100644 --- a/mlbTVOS/Views/GameCardView.swift +++ b/mlbTVOS/Views/GameCardView.swift @@ -3,11 +3,12 @@ import SwiftUI struct GameCardView: View { let game: Game let onSelect: () -> Void + @Environment(GamesViewModel.self) private var viewModel private var inMultiView: Bool { - game.broadcasts.contains(where: { bc in - viewModel.activeStreams.contains(where: { $0.id == bc.id }) + game.broadcasts.contains(where: { broadcast in + viewModel.activeStreams.contains(where: { $0.id == broadcast.id }) }) } @@ -16,160 +17,312 @@ struct GameCardView: View { var body: some View { Button(action: onSelect) { - VStack(spacing: 0) { - // Team color accent bar - HStack(spacing: 0) { - Rectangle().fill(awayColor) - Rectangle().fill(homeColor) - } - .frame(height: 4) - - VStack(spacing: rowGap) { - teamRow(team: game.awayTeam, isWinning: isWinning(away: true)) - teamRow(team: game.homeTeam, isWinning: isWinning(away: false)) - } - .padding(.horizontal, cardPadH) - .padding(.top, cardPadV) - - Spacer(minLength: 6) - - // Footer: status + linescore - HStack { - statusPill - - Spacer() - - if let linescore = game.linescore, !game.status.isScheduled { - MiniLinescoreView( - linescore: linescore, - awayCode: game.awayTeam.code, - homeCode: game.homeTeam.code - ) - } - } - .padding(.horizontal, cardPadH) - .padding(.bottom, cardPadV) + VStack(alignment: .leading, spacing: cardSpacing) { + headerRow + matchupBlock + footerBlock } .frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .topLeading) - .background(DS.Colors.panelFill) + .padding(cardPad) + .background(cardBackground) + .overlay(cardBorder) .clipShape(RoundedRectangle(cornerRadius: cardRadius, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: cardRadius, style: .continuous) - .strokeBorder(borderColor, lineWidth: borderWidth) - ) .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) } .platformCardStyle() } - @ViewBuilder - private func teamRow(team: TeamInfo, isWinning: Bool) -> some View { - HStack(spacing: teamSpacing) { - TeamLogoView(team: team, size: logoSize) + private var headerRow: some View { + HStack(alignment: .center, spacing: 12) { + statusPill + Spacer(minLength: 8) - Text(team.code) - .font(codeFont) - .foregroundStyle(DS.Colors.textPrimary) - .frame(width: codeWidth, alignment: .leading) - - Text(team.displayName) - .font(nameFont) - .foregroundStyle(DS.Colors.textSecondary) - .lineLimit(1) - - Spacer(minLength: 4) - - if let record = team.record { - Text(record) - .font(recordFont) - .foregroundStyle(DS.Colors.textTertiary) + if inMultiView { + chip(title: "In Multi-View", tint: DS.Colors.positive) } - if let streak = team.streak { - Text(streak) - .font(recordFont) - .foregroundStyle(streak.hasPrefix("W") ? DS.Colors.positive : DS.Colors.live) - } - - if !game.status.isScheduled, let score = team.score { - Text("\(score)") - .font(scoreFont) - .foregroundStyle(isWinning ? DS.Colors.textPrimary : DS.Colors.textTertiary) - .frame(width: scoreWidth, alignment: .trailing) + if game.hasStreams { + chip(title: "\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", tint: DS.Colors.interactive) } } } + private var matchupBlock: some View { + VStack(spacing: 16) { + teamRow(team: game.awayTeam, isLeading: isWinning(away: true)) + teamRow(team: game.homeTeam, isLeading: isWinning(away: false)) + } + } + + @ViewBuilder + private var footerBlock: some View { + VStack(alignment: .leading, spacing: 14) { + switch game.status { + case .live: + liveFooter + case .final_: + finalFooter + case .scheduled: + scheduledFooter + case .unknown: + unknownFooter + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + } + + private func teamRow(team: TeamInfo, isLeading: Bool) -> some View { + HStack(spacing: 14) { + TeamLogoView(team: team, size: logoSize) + + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 10) { + Text(team.code) + .font(codeFont) + .foregroundStyle(.white) + + if let record = team.record { + Text(record) + .font(metaFont) + .foregroundStyle(DS.Colors.textSecondary) + } + } + + Text(team.displayName) + .font(nameFont) + .foregroundStyle(DS.Colors.textSecondary) + .lineLimit(1) + + if let summary = team.standingSummary { + Text(summary) + .font(metaFont) + .foregroundStyle(DS.Colors.textTertiary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + Text(team.score.map(String.init) ?? "—") + .font(scoreFont) + .foregroundStyle(isLeading ? .white : DS.Colors.textSecondary) + .monospacedDigit() + } + } + + @ViewBuilder + private var liveFooter: some View { + if let linescore = game.linescore, !game.status.isScheduled { + HStack(alignment: .bottom, spacing: 16) { + VStack(alignment: .leading, spacing: 10) { + Text(game.currentInningDisplay ?? "Live") + .font(footerTitleFont) + .foregroundStyle(DS.Colors.live) + + if let awayRuns = linescore.teams?.away?.runs, + let homeRuns = linescore.teams?.home?.runs, + let awayHits = linescore.teams?.away?.hits, + let homeHits = linescore.teams?.home?.hits { + HStack(spacing: 10) { + footerMetric(label: game.awayTeam.code, value: "\(awayRuns)R \(awayHits)H") + footerMetric(label: game.homeTeam.code, value: "\(homeRuns)R \(homeHits)H") + } + } + } + + Spacer(minLength: 12) + + DiamondView( + balls: linescore.balls ?? 0, + strikes: linescore.strikes ?? 0, + outs: linescore.outs ?? 0 + ) + } + + MiniLinescoreView( + linescore: linescore, + awayCode: game.awayTeam.code, + homeCode: game.homeTeam.code + ) + } else { + Text("Live update available") + .font(footerBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + } + } + + private var finalFooter: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Final") + .font(footerTitleFont) + .foregroundStyle(DS.Colors.positive) + + Text(game.scoreDisplay ?? "Game complete") + .font(footerValueFont) + .foregroundStyle(.white) + + if let venue = game.venue { + Text(venue) + .font(footerBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + } + } + } + + private var scheduledFooter: some View { + VStack(alignment: .leading, spacing: 10) { + Text(game.startTime ?? game.status.label) + .font(footerTitleFont) + .foregroundStyle(DS.Colors.warning) + + Text(game.pitchers ?? "Probable pitchers pending") + .font(footerBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + .lineLimit(2) + + if let venue = game.venue { + Text(venue) + .font(metaFont) + .foregroundStyle(DS.Colors.textTertiary) + .lineLimit(1) + } + } + } + + private var unknownFooter: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Awaiting update") + .font(footerTitleFont) + .foregroundStyle(DS.Colors.textSecondary) + + if let venue = game.venue { + Text(venue) + .font(footerBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + } + } + } + + private func footerMetric(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(metaFont) + .foregroundStyle(DS.Colors.textTertiary) + Text(value) + .font(footerValueFont) + .foregroundStyle(.white) + .monospacedDigit() + } + } + + private func chip(title: String, tint: Color) -> some View { + Text(title) + .font(chipFont) + .foregroundStyle(tint) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule() + .fill(tint.opacity(0.12)) + .overlay { + Capsule() + .strokeBorder(tint.opacity(0.22), lineWidth: 1) + } + ) + } + @ViewBuilder private var statusPill: some View { switch game.status { case .live(let inning): - HStack(spacing: 6) { - Circle().fill(DS.Colors.live).frame(width: 8, height: 8) - Text(inning ?? "LIVE") - .font(statusFont) - .foregroundStyle(DS.Colors.live) - } - + chip(title: inning?.uppercased() ?? "LIVE", tint: DS.Colors.live) case .scheduled(let time): - Text(time) - .font(statusFont) - .foregroundStyle(DS.Colors.textSecondary) - + chip(title: time.uppercased(), tint: DS.Colors.warning) case .final_: - Text("FINAL") - .font(statusFont) - .foregroundStyle(DS.Colors.textTertiary) - + chip(title: "FINAL", tint: DS.Colors.positive) case .unknown: - EmptyView() + chip(title: "PENDING", tint: DS.Colors.textTertiary) } } + private var cardBackground: some View { + RoundedRectangle(cornerRadius: cardRadius, style: .continuous) + .fill( + LinearGradient( + colors: [ + DS.Colors.panelFill, + DS.Colors.panelFillMuted, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay(alignment: .top) { + Rectangle() + .fill( + LinearGradient( + colors: [awayColor, homeColor], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(height: 5) + .clipShape( + RoundedRectangle(cornerRadius: cardRadius, style: .continuous) + ) + } + } + + private var cardBorder: some View { + RoundedRectangle(cornerRadius: cardRadius, style: .continuous) + .strokeBorder(borderColor, lineWidth: borderWidth) + } + private func isWinning(away: Bool) -> Bool { - guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false } - return away ? a > h : h > a + guard let awayScore = game.awayTeam.score, let homeScore = game.homeTeam.score else { + return false + } + return away ? awayScore > homeScore : homeScore > awayScore } private var borderColor: Color { - if inMultiView { return DS.Colors.positive.opacity(0.5) } - if game.isLive { return DS.Colors.live.opacity(0.3) } + if inMultiView { return DS.Colors.positive.opacity(0.46) } + if game.isLive { return DS.Colors.live.opacity(0.34) } return DS.Colors.panelStroke } private var borderWidth: CGFloat { - inMultiView || game.isLive ? 2 : 0.5 + inMultiView || game.isLive ? 1.6 : 1 } #if os(tvOS) - private var cardHeight: CGFloat { 200 } - private var cardRadius: CGFloat { 22 } - private var cardPadH: CGFloat { 22 } - private var cardPadV: CGFloat { 16 } - private var rowGap: CGFloat { 10 } - private var logoSize: CGFloat { 44 } - private var teamSpacing: CGFloat { 14 } - private var codeWidth: CGFloat { 60 } - private var scoreWidth: CGFloat { 40 } - private var codeFont: Font { .system(size: 26, weight: .black, design: .rounded) } - private var nameFont: Font { .system(size: 22, weight: .semibold) } - private var scoreFont: Font { .system(size: 30, weight: .black, design: .rounded).monospacedDigit() } - private var recordFont: Font { .system(size: 20, weight: .bold, design: .monospaced) } - private var statusFont: Font { .system(size: 22, weight: .bold, design: .rounded) } + private var cardHeight: CGFloat { 270 } + private var cardRadius: CGFloat { 28 } + private var cardPad: CGFloat { 24 } + private var cardSpacing: CGFloat { 18 } + private var logoSize: CGFloat { 46 } + private var codeFont: Font { .system(size: 24, weight: .black, design: .rounded) } + private var nameFont: Font { .system(size: 22, weight: .bold, design: .rounded) } + private var metaFont: Font { .system(size: 15, weight: .bold, design: .rounded) } + private var scoreFont: Font { .system(size: 38, weight: .black, design: .rounded).monospacedDigit() } + private var chipFont: Font { .system(size: 13, weight: .black, design: .rounded) } + private var footerTitleFont: Font { .system(size: 18, weight: .black, design: .rounded) } + private var footerValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) } + private var footerBodyFont: Font { .system(size: 16, weight: .semibold) } #else - private var cardHeight: CGFloat { 150 } - private var cardRadius: CGFloat { 18 } - private var cardPadH: CGFloat { 16 } - private var cardPadV: CGFloat { 12 } - private var rowGap: CGFloat { 8 } - private var logoSize: CGFloat { 32 } - private var teamSpacing: CGFloat { 10 } - private var codeWidth: CGFloat { 44 } - private var scoreWidth: CGFloat { 30 } + private var cardHeight: CGFloat { 200 } + private var cardRadius: CGFloat { 20 } + private var cardPad: CGFloat { 18 } + private var cardSpacing: CGFloat { 14 } + private var logoSize: CGFloat { 34 } private var codeFont: Font { .system(size: 18, weight: .black, design: .rounded) } - private var nameFont: Font { .system(size: 14, weight: .semibold) } - private var scoreFont: Font { .system(size: 22, weight: .black, design: .rounded).monospacedDigit() } - private var recordFont: Font { .system(size: 13, weight: .bold, design: .monospaced) } - private var statusFont: Font { .system(size: 14, weight: .bold, design: .rounded) } + private var nameFont: Font { .system(size: 16, weight: .bold, design: .rounded) } + private var metaFont: Font { .system(size: 11, weight: .bold, design: .rounded) } + private var scoreFont: Font { .system(size: 28, weight: .black, design: .rounded).monospacedDigit() } + private var chipFont: Font { .system(size: 10, weight: .black, design: .rounded) } + private var footerTitleFont: Font { .system(size: 13, weight: .black, design: .rounded) } + private var footerValueFont: Font { .system(size: 13, weight: .bold, design: .rounded) } + private var footerBodyFont: Font { .system(size: 12, weight: .semibold) } #endif } diff --git a/mlbTVOS/Views/LeagueCenterView.swift b/mlbTVOS/Views/LeagueCenterView.swift index 779a484..0794273 100644 --- a/mlbTVOS/Views/LeagueCenterView.swift +++ b/mlbTVOS/Views/LeagueCenterView.swift @@ -34,6 +34,8 @@ struct LeagueCenterView: View { messagePanel(overviewErrorMessage, tint: .orange) } + scheduleSection + #if os(tvOS) // Side-by-side: standings left, leaders right HStack(alignment: .top, spacing: 24) { @@ -86,11 +88,11 @@ struct LeagueCenterView: View { private var header: some View { HStack(alignment: .top, spacing: 24) { VStack(alignment: .leading, spacing: 8) { - Text("Around MLB") + Text("League Center") .font(.system(size: 42, weight: .bold, design: .rounded)) .foregroundStyle(DS.Colors.textPrimary) - Text("Standings, league leaders, team context, roster access, and player snapshots in one control room.") + Text("Schedule navigation, standings, league leaders, roster access, and player snapshots in one board.") .font(.system(size: 16, weight: .medium)) .foregroundStyle(DS.Colors.textTertiary) } @@ -100,7 +102,7 @@ struct LeagueCenterView: View { HStack(spacing: 12) { infoPill(title: "\(viewModel.leagueLeaders.count)", label: "Leaders", color: .blue) infoPill(title: "\(viewModel.teams.count)", label: "Teams", color: .green) - infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange) + infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .orange) } } } @@ -737,15 +739,24 @@ struct LeagueCenterView: View { private var sectionPanel: some View { RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(DS.Colors.panelFill) + .fill( + LinearGradient( + colors: [ + DS.Colors.panelFill, + DS.Colors.panelFillMuted, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) } } private var screenBackground: some View { - DS.Colors.background + BroadcastBackground() } } diff --git a/mlbTVOS/Views/SettingsView.swift b/mlbTVOS/Views/SettingsView.swift index 223315b..dd4eb2e 100644 --- a/mlbTVOS/Views/SettingsView.swift +++ b/mlbTVOS/Views/SettingsView.swift @@ -14,60 +14,260 @@ struct SettingsView: View { var body: some View { @Bindable var vm = viewModel - NavigationStack { - Form { - Section("Server") { - LabeledContent("URL", value: viewModel.serverBaseURL) + ScrollView { + VStack(alignment: .leading, spacing: 26) { + header + + settingsPanel( + title: "Server", + subtitle: "Current upstream endpoint and playback defaults." + ) { + infoRow(label: "Base URL", value: viewModel.serverBaseURL) + infoRow(label: "Current Quality", value: resolutionLabel(for: viewModel.defaultResolution)) } - Section("Default Quality") { - ForEach(resolutions, id: \.0) { res in - Button { - vm.defaultResolution = res.0 - } label: { - HStack { - Text(res.1) - Spacer() - if viewModel.defaultResolution == res.0 { - Image(systemName: "checkmark") - .foregroundStyle(.blue) + settingsPanel( + title: "Playback Quality", + subtitle: "Preferred stream profile for newly opened feeds." + ) { + VStack(spacing: 12) { + ForEach(resolutions, id: \.0) { resolution in + Button { + vm.defaultResolution = resolution.0 + } label: { + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + Text(resolution.1) + .font(optionTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + + Text(resolutionDescription(for: resolution.0)) + .font(optionBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + } + + Spacer() + + Image(systemName: viewModel.defaultResolution == resolution.0 ? "checkmark.circle.fill" : "circle") + .font(.system(size: indicatorSize, weight: .bold)) + .foregroundStyle(viewModel.defaultResolution == resolution.0 ? DS.Colors.interactive : DS.Colors.textQuaternary) } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background(optionBackground(selected: viewModel.defaultResolution == resolution.0)) } + .platformCardStyle() } } } - Section("Active Streams (\(viewModel.activeStreams.count)/4)") { + settingsPanel( + title: "Active Streams", + subtitle: "Tile occupancy and cleanup controls for Multi-View." + ) { if viewModel.activeStreams.isEmpty { - Text("No active streams") - .foregroundStyle(.secondary) + Text("No active streams. Add broadcasts from the dashboard to populate the grid.") + .font(optionBodyFont) + .foregroundStyle(DS.Colors.textSecondary) } else { - ForEach(viewModel.activeStreams) { stream in - HStack { - Text(stream.label) - .fontWeight(.bold) - Text(stream.game.displayTitle) - .foregroundStyle(.secondary) - Spacer() - Button(role: .destructive) { - viewModel.removeStream(id: stream.id) - } label: { - Image(systemName: "trash") + VStack(spacing: 12) { + ForEach(viewModel.activeStreams) { stream in + HStack(spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + Text(stream.label) + .font(optionTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + + Text(stream.game.displayTitle) + .font(optionBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + } + + Spacer() + + Button(role: .destructive) { + viewModel.removeStream(id: stream.id) + } label: { + Label("Remove", systemImage: "trash") + .font(actionFont) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Capsule() + .fill(DS.Colors.live.opacity(0.82)) + ) + } + .platformCardStyle() } + .padding(.horizontal, 18) + .padding(.vertical, 16) + .background(optionBackground(selected: false)) } } - Button("Clear All Streams", role: .destructive) { + + Button(role: .destructive) { viewModel.clearAllStreams() + } label: { + Label("Clear All Streams", systemImage: "xmark.circle.fill") + .font(actionFont) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .padding(.vertical, 14) + .background( + Capsule() + .fill(DS.Colors.live.opacity(0.82)) + ) } + .platformCardStyle() } } - Section("About") { - LabeledContent("Version", value: "1.0") - LabeledContent("Server", value: "mlbserver") + settingsPanel( + title: "About", + subtitle: "Build and environment context." + ) { + infoRow(label: "Version", value: "1.0") + infoRow(label: "Player", value: "mlbserver") + infoRow(label: "Active Tiles", value: "\(viewModel.activeStreams.count)/4") } } - .navigationTitle("Settings") + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) } } + + private var header: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Settings") + .font(headerTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + + Text("Playback defaults, server routing, and Multi-View controls.") + .font(headerBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + } + } + + private func settingsPanel( + title: String, + subtitle: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(sectionTitleFont) + .foregroundStyle(DS.Colors.textPrimary) + + Text(subtitle) + .font(sectionBodyFont) + .foregroundStyle(DS.Colors.textSecondary) + } + + content() + } + .padding(panelPadding) + .background(panelBackground) + } + + private func infoRow(label: String, value: String) -> some View { + HStack(alignment: .top, spacing: 16) { + Text(label) + .font(infoLabelFont) + .foregroundStyle(DS.Colors.textTertiary) + .frame(width: labelWidth, alignment: .leading) + + Text(value) + .font(infoValueFont) + .foregroundStyle(DS.Colors.textPrimary) + + Spacer(minLength: 0) + } + } + + private func optionBackground(selected: Bool) -> some View { + RoundedRectangle(cornerRadius: optionRadius, style: .continuous) + .fill(selected ? DS.Colors.interactive.opacity(0.14) : DS.Colors.panelFillMuted) + .overlay { + RoundedRectangle(cornerRadius: optionRadius, style: .continuous) + .strokeBorder(selected ? DS.Colors.interactive.opacity(0.26) : DS.Colors.panelStroke, lineWidth: 1) + } + } + + private func resolutionLabel(for key: String) -> String { + resolutions.first(where: { $0.0 == key })?.1 ?? key + } + + private func resolutionDescription(for key: String) -> String { + switch key { + case "best": + return "Uses the highest reliable stream available." + case "1080p60": + return "Prioritizes smooth full-HD playback when available." + case "720p60": + return "Balanced quality for lighter network conditions." + case "540p": + return "Lower bandwidth option for stability." + case "adaptive": + return "Lets the player adjust dynamically." + default: + return "Custom playback preference." + } + } + + private var panelBackground: some View { + RoundedRectangle(cornerRadius: panelRadius, style: .continuous) + .fill( + LinearGradient( + colors: [ + DS.Colors.panelFill, + DS.Colors.panelFillMuted, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay { + RoundedRectangle(cornerRadius: panelRadius, style: .continuous) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 1) + } + .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) + } + + #if os(tvOS) + private var horizontalPadding: CGFloat { 56 } + private var verticalPadding: CGFloat { 40 } + private var panelPadding: CGFloat { 24 } + private var panelRadius: CGFloat { 28 } + private var optionRadius: CGFloat { 22 } + private var labelWidth: CGFloat { 150 } + private var indicatorSize: CGFloat { 22 } + private var headerTitleFont: Font { .system(size: 44, weight: .black, design: .rounded) } + private var headerBodyFont: Font { .system(size: 18, weight: .medium) } + private var sectionTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) } + private var sectionBodyFont: Font { .system(size: 16, weight: .medium) } + private var infoLabelFont: Font { .system(size: 15, weight: .black, design: .rounded) } + private var infoValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) } + private var optionTitleFont: Font { .system(size: 20, weight: .bold, design: .rounded) } + private var optionBodyFont: Font { .system(size: 15, weight: .medium) } + private var actionFont: Font { .system(size: 14, weight: .black, design: .rounded) } + #else + private var horizontalPadding: CGFloat { 20 } + private var verticalPadding: CGFloat { 24 } + private var panelPadding: CGFloat { 18 } + private var panelRadius: CGFloat { 22 } + private var optionRadius: CGFloat { 18 } + private var labelWidth: CGFloat { 100 } + private var indicatorSize: CGFloat { 18 } + private var headerTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) } + private var headerBodyFont: Font { .system(size: 14, weight: .medium) } + private var sectionTitleFont: Font { .system(size: 20, weight: .black, design: .rounded) } + private var sectionBodyFont: Font { .system(size: 13, weight: .medium) } + private var infoLabelFont: Font { .system(size: 12, weight: .black, design: .rounded) } + private var infoValueFont: Font { .system(size: 14, weight: .bold, design: .rounded) } + private var optionTitleFont: Font { .system(size: 16, weight: .bold, design: .rounded) } + private var optionBodyFont: Font { .system(size: 12, weight: .medium) } + private var actionFont: Font { .system(size: 12, weight: .black, design: .rounded) } + #endif }