From 65ad41840f43a7d1daff214d04124a00fb131f8e Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 12 Apr 2026 14:56:26 -0500 Subject: [PATCH] Complete visual overhaul: warm light theme, stadium hero, inline pill nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired by vtv/Dribbble streaming concept. Every dark surface replaced with warm off-white (#F5F3F0) and white cards with soft shadows. Design System: Warm off-white background, white card fills, subtle drop shadows, dark text hierarchy, warm orange accent (#F27326) replacing blue as primary interactive color. Added onDark color variants for hero overlays. Shadow system with card/lifted states. Navigation: Replaced TabView with inline CategoryPillBar — horizontal orange pills (Today | Intel | Highlights | Multi-View | Settings). Single scrolling view, no system chrome. Multi-View as icon button with stream count badge. Settings as gear icon. Stadium Hero: Full-bleed stadium photos from MLB CDN (mlbstatic.com/v1/venue/{id}/spots/1200) as featured game background. Left gradient overlay for text readability. Live games show score + inning + DiamondView count/outs. Scheduled games show probable pitchers with headshots + records. Final games show final score. Warm orange "Watch Now" CTA pill. Added venue ID mapping for all 30 stadiums to TeamAssets. Game Cards: White cards with team color top bar, horizontal team rows, dark text, soft shadows. Record + streak on every card. Intel Tab: All dark panels replaced with white cards + shadows. Replaced dark gradient screen background with flat warm off-white. 58 hardcoded .white.opacity() values replaced with DS.Colors tokens. Feed Tab: Already used DS.Colors — inherits light theme automatically. Focus: tvOS focus style uses warm orange border highlight + lifted shadow instead of white glow on dark. Co-Authored-By: Claude Opus 4.6 (1M context) --- mlbTVOS.xcodeproj/project.pbxproj | 6 + mlbTVOS/Models/TeamAssets.swift | 17 + .../Views/Components/CategoryPillBar.swift | 98 ++++ mlbTVOS/Views/Components/DataPanel.swift | 3 +- mlbTVOS/Views/Components/DesignSystem.swift | 45 +- mlbTVOS/Views/Components/PlatformUI.swift | 16 +- mlbTVOS/Views/ContentView.swift | 55 ++- mlbTVOS/Views/DashboardView.swift | 36 +- mlbTVOS/Views/FeaturedGameCard.swift | 464 +++++++++--------- mlbTVOS/Views/GameCardView.swift | 205 ++++---- mlbTVOS/Views/LeagueCenterView.swift | 121 ++--- 11 files changed, 582 insertions(+), 484 deletions(-) create mode 100644 mlbTVOS/Views/Components/CategoryPillBar.swift diff --git a/mlbTVOS.xcodeproj/project.pbxproj b/mlbTVOS.xcodeproj/project.pbxproj index b2cc552..5b2b8a6 100644 --- a/mlbTVOS.xcodeproj/project.pbxproj +++ b/mlbTVOS.xcodeproj/project.pbxproj @@ -97,6 +97,8 @@ 6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C95E73FFDAFE2E26B06C2 /* FeedView.swift */; }; DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */; }; 94D2242FE48B5FECE15116FF /* LeaderboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */; }; + FA82D0AAB5FE0222ECF35105 /* CategoryPillBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5381B6E058D1194E844106D /* CategoryPillBar.swift */; }; + D3470E2BF99541CF2A3ADC2A /* CategoryPillBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5381B6E058D1194E844106D /* CategoryPillBar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -165,6 +167,7 @@ 9F68D38B739C81D7747CC412 /* FeedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemView.swift; sourceTree = ""; }; 981C95E73FFDAFE2E26B06C2 /* FeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedView.swift; sourceTree = ""; }; 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardView.swift; sourceTree = ""; }; + D5381B6E058D1194E844106D /* CategoryPillBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPillBar.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -207,6 +210,7 @@ children = ( C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */, 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */, + D5381B6E058D1194E844106D /* CategoryPillBar.swift */, 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */, 9F68D38B739C81D7747CC412 /* FeedItemView.swift */, A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */, @@ -440,6 +444,7 @@ 8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */, E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */, 4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.swift in Sources */, + D3470E2BF99541CF2A3ADC2A /* CategoryPillBar.swift in Sources */, 94D2242FE48B5FECE15116FF /* LeaderboardView.swift in Sources */, 6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */, 9BF7942D0252B0EB87DE58B3 /* FeedItemView.swift in Sources */, @@ -499,6 +504,7 @@ 07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */, B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */, 051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */, + FA82D0AAB5FE0222ECF35105 /* CategoryPillBar.swift in Sources */, DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */, C5057ECFA9FCBFFC6A8A7E15 /* FeedView.swift in Sources */, 5802E2BE747B7B59F00AE1E7 /* FeedItemView.swift in Sources */, diff --git a/mlbTVOS/Models/TeamAssets.swift b/mlbTVOS/Models/TeamAssets.swift index 0543dfe..6228298 100644 --- a/mlbTVOS/Models/TeamAssets.swift +++ b/mlbTVOS/Models/TeamAssets.swift @@ -59,4 +59,21 @@ enum TeamAssets { static func logoURL(forId id: Int) -> URL { URL(string: "https://midfield.mlbstatic.com/v1/team/\(id)/spots/72")! } + + // Venue IDs for stadium hero photos + static let venueIds: [String: Int] = [ + "ARI": 15, "AZ": 15, "ATL": 4705, "BAL": 2, "BOS": 3, + "CHC": 17, "CWS": 4, "CIN": 2602, "CLE": 5, "COL": 19, + "DET": 2394, "HOU": 2392, "KC": 7, "LAA": 1, + "LAD": 22, "MIA": 4169, "MIL": 32, "MIN": 3312, + "NYM": 3289, "NYY": 3313, "OAK": 10, "ATH": 10, + "PHI": 2681, "PIT": 31, "SD": 2680, "SF": 2395, + "SEA": 680, "STL": 2889, "TB": 12, "TEX": 5325, + "TOR": 14, "WSH": 3309, + ] + + static func stadiumURL(for code: String) -> URL? { + guard let id = venueIds[code.uppercased()] else { return nil } + return URL(string: "https://midfield.mlbstatic.com/v1/venue/\(id)/spots/1200") + } } diff --git a/mlbTVOS/Views/Components/CategoryPillBar.swift b/mlbTVOS/Views/Components/CategoryPillBar.swift new file mode 100644 index 0000000..673fb53 --- /dev/null +++ b/mlbTVOS/Views/Components/CategoryPillBar.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct CategoryPillBar: View { + @Binding var selected: AppSection + var streamCount: Int = 0 + + 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() + } + } + } + } + + #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 } + #else + private var pillSpacing: CGFloat { 4 } + private var pillPadH: CGFloat { 18 } + private var pillPadV: CGFloat { 10 } + private var pillFont: Font { .system(size: 15, weight: .bold, design: .rounded) } + private var iconSize: CGFloat { 16 } + #endif +} + +enum AppSection: String, CaseIterable, Identifiable { + case today + case intel + case highlights + case multiView + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .today: "Today" + case .intel: "Intel" + case .highlights: "Highlights" + case .multiView: "Multi-View" + case .settings: "Settings" + } + } +} diff --git a/mlbTVOS/Views/Components/DataPanel.swift b/mlbTVOS/Views/Components/DataPanel.swift index 9a754ff..c3ffe5a 100644 --- a/mlbTVOS/Views/Components/DataPanel.swift +++ b/mlbTVOS/Views/Components/DataPanel.swift @@ -46,6 +46,7 @@ struct DataPanel: View { .background( RoundedRectangle(cornerRadius: density.cornerRadius) .fill(DS.Colors.panelFill) + .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) ) .overlay( RoundedRectangle(cornerRadius: density.cornerRadius) @@ -54,8 +55,6 @@ struct DataPanel: View { } } -// MARK: - Convenience initializers - extension DataPanel { init( _ density: DataPanelDensity = .standard, diff --git a/mlbTVOS/Views/Components/DesignSystem.swift b/mlbTVOS/Views/Components/DesignSystem.swift index cc4c08f..ab2c841 100644 --- a/mlbTVOS/Views/Components/DesignSystem.swift +++ b/mlbTVOS/Views/Components/DesignSystem.swift @@ -1,25 +1,41 @@ import SwiftUI -// MARK: - The Dugout Design System +// MARK: - The Dugout Design System — Warm Light Theme enum DS { // MARK: - Colors enum Colors { - static let background = Color(red: 0.02, green: 0.03, blue: 0.05) - static let panelFill = Color.white.opacity(0.04) - static let panelStroke = Color.white.opacity(0.06) + 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 live = Color(red: 0.95, green: 0.22, blue: 0.22) - static let positive = Color(red: 0.20, green: 0.78, blue: 0.35) - static let warning = Color(red: 0.95, green: 0.55, blue: 0.15) - static let interactive = Color(red: 0.25, green: 0.52, blue: 0.95) - static let media = Color(red: 0.55, green: 0.35, blue: 0.85) + 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 textPrimary = Color.white - static let textSecondary = Color.white.opacity(0.7) - static let textTertiary = Color.white.opacity(0.45) - static let textQuaternary = Color.white.opacity(0.2) + 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) + + // 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) + } + + // 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 } // MARK: - Typography @@ -42,6 +58,7 @@ enum DS { // 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 tvCardTitle = Font.system(size: 28, weight: .bold, design: .rounded) static let tvScore = Font.system(size: 36, weight: .black, design: .rounded).monospacedDigit() @@ -94,7 +111,7 @@ struct DataLabelStyle: ViewModifier { .font(DS.Fonts.caption) .kerning(1.5) #endif - .foregroundStyle(DS.Colors.textQuaternary) + .foregroundStyle(DS.Colors.textTertiary) .textCase(.uppercase) } } diff --git a/mlbTVOS/Views/Components/PlatformUI.swift b/mlbTVOS/Views/Components/PlatformUI.swift index 5620f90..bbd9968 100644 --- a/mlbTVOS/Views/Components/PlatformUI.swift +++ b/mlbTVOS/Views/Components/PlatformUI.swift @@ -11,7 +11,7 @@ struct PlatformPressButtonStyle: ButtonStyle { } } -// MARK: - tvOS Focus Style +// MARK: - tvOS Focus Style (Light Theme) #if os(tvOS) struct TVFocusButtonStyle: ButtonStyle { @@ -21,14 +21,14 @@ struct TVFocusButtonStyle: ButtonStyle { configuration.label .scaleEffect(configuration.isPressed ? 0.97 : isFocused ? 1.04 : 1.0) .opacity(configuration.isPressed ? 0.85 : 1.0) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(.white.opacity(isFocused ? 0.3 : 0), lineWidth: 2) - ) .shadow( - color: isFocused ? .white.opacity(0.12) : .clear, - radius: isFocused ? 20 : 0, - y: isFocused ? 8 : 0 + color: isFocused ? DS.Shadows.cardLifted : .clear, + radius: isFocused ? DS.Shadows.cardLiftedRadius : 0, + y: isFocused ? DS.Shadows.cardLiftedY : 0 + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(DS.Colors.interactive.opacity(isFocused ? 0.5 : 0), lineWidth: 2.5) ) .animation(.easeInOut(duration: 0.2), value: isFocused) .animation(.easeOut(duration: 0.12), value: configuration.isPressed) diff --git a/mlbTVOS/Views/ContentView.swift b/mlbTVOS/Views/ContentView.swift index f6f223d..723f87f 100644 --- a/mlbTVOS/Views/ContentView.swift +++ b/mlbTVOS/Views/ContentView.swift @@ -2,35 +2,44 @@ import SwiftUI struct ContentView: View { @Environment(GamesViewModel.self) private var viewModel - - private var multiViewLabel: String { - let count = viewModel.activeStreams.count - if count > 0 { - return "Multi-View (\(count))" - } - return "Multi-View" - } + @State private var selectedSection: AppSection = .today var body: some View { - TabView { - Tab("Today", systemImage: "sportscourt.fill") { - DashboardView() - } - Tab("Intel", systemImage: "chart.bar.fill") { - LeagueCenterView() - } - Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") { - MultiStreamView() - } - Tab("Feed", systemImage: "newspaper.fill") { - FeedView() - } - Tab("Settings", systemImage: "gearshape.fill") { - SettingsView() + 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) + + // Content area + Group { + switch selectedSection { + case .today: + DashboardView() + case .intel: + LeagueCenterView() + case .highlights: + FeedView() + case .multiView: + MultiStreamView() + case .settings: + SettingsView() + } } } + .background(DS.Colors.background) .task { await viewModel.loadGames() } } + + #if os(tvOS) + private var navPadV: CGFloat { 20 } + #else + private var navPadV: CGFloat { 12 } + #endif } diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index 85d5d82..1cb0c3c 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -86,7 +86,7 @@ struct DashboardView: View { #if os(iOS) horizontalSizeClass == .compact ? 340 : 480 #else - 580 + 640 #endif } @@ -349,7 +349,7 @@ struct DashboardView: View { VStack(alignment: .leading, spacing: 20) { Label(title, systemImage: icon) .font(.title3.weight(.bold)) - .foregroundStyle(.secondary) + .foregroundStyle(DS.Colors.textSecondary) ScrollView(.horizontal) { LazyHStack(spacing: 30) { @@ -376,10 +376,11 @@ struct DashboardView: View { VStack(alignment: .leading, spacing: 6) { Text("MLB") .font(.headline.weight(.black)) - .foregroundStyle(.secondary) + .foregroundStyle(DS.Colors.textTertiary) .kerning(4) Text(viewModel.displayDateString) .font(.system(size: 40, weight: .bold)) + .foregroundStyle(DS.Colors.textPrimary) .contentTransition(.numericText()) } @@ -438,11 +439,8 @@ struct DashboardView: View { .padding(.horizontal, 20) .padding(.vertical, 10) .background(DS.Colors.panelFill) - .overlay( - RoundedRectangle(cornerRadius: DS.Radii.compact) - .strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5) - ) .clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact)) + .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) } // MARK: - Featured Channels @@ -482,9 +480,10 @@ struct DashboardView: View { VStack(alignment: .leading, spacing: 4) { Text("MLB Network") .font(.title3.weight(.bold)) + .foregroundStyle(DS.Colors.textPrimary) Text("Live coverage, analysis & highlights") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(DS.Colors.textSecondary) } Spacer() @@ -492,13 +491,14 @@ struct DashboardView: View { if added { Label("In Multi-View", systemImage: "checkmark.circle.fill") .font(.subheadline.weight(.bold)) - .foregroundStyle(.green) + .foregroundStyle(DS.Colors.positive) } } .frame(maxWidth: .infinity, minHeight: 108, alignment: .leading) .padding(24) - .background(.regularMaterial) + .background(DS.Colors.panelFill) .clipShape(RoundedRectangle(cornerRadius: 20)) + .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) } .platformCardStyle() } @@ -520,9 +520,10 @@ struct DashboardView: View { VStack(alignment: .leading, spacing: 4) { Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle) .font(.title3.weight(.bold)) + .foregroundStyle(DS.Colors.textPrimary) Text(SpecialPlaybackChannelConfig.werkoutNSFWSubtitle) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(DS.Colors.textSecondary) } Spacer() @@ -530,7 +531,7 @@ struct DashboardView: View { if added { Label("In Multi-View", systemImage: "checkmark.circle.fill") .font(.subheadline.weight(.bold)) - .foregroundStyle(.green) + .foregroundStyle(DS.Colors.positive) } else { Label("Open", systemImage: "play.fill") .font(.subheadline.weight(.bold)) @@ -539,8 +540,9 @@ struct DashboardView: View { } .frame(maxWidth: .infinity, minHeight: 108, alignment: .leading) .padding(24) - .background(.regularMaterial) + .background(DS.Colors.panelFill) .clipShape(RoundedRectangle(cornerRadius: 20)) + .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) } .platformCardStyle() } @@ -552,19 +554,21 @@ struct DashboardView: View { VStack(alignment: .leading, spacing: 14) { Label("Multi-View", systemImage: "rectangle.split.2x2") .font(.title3.weight(.bold)) - .foregroundStyle(.secondary) + .foregroundStyle(DS.Colors.textSecondary) HStack(spacing: 12) { ForEach(viewModel.activeStreams) { stream in HStack(spacing: 8) { - Circle().fill(.green).frame(width: 8, height: 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(.regularMaterial) + .background(DS.Colors.panelFill) .clipShape(Capsule()) + .shadow(color: DS.Shadows.card, radius: 8, y: 2) } } } diff --git a/mlbTVOS/Views/FeaturedGameCard.swift b/mlbTVOS/Views/FeaturedGameCard.swift index 01c3bdd..a95f5a4 100644 --- a/mlbTVOS/Views/FeaturedGameCard.swift +++ b/mlbTVOS/Views/FeaturedGameCard.swift @@ -8,242 +8,224 @@ struct FeaturedGameCard: View { private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) } private var awayPitcherName: String? { - guard let pitchers = game.pitchers else { return nil } - return pitchers.components(separatedBy: " vs ").first + 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 homePitcherName: String? { - guard let pitchers = game.pitchers else { return nil } - let parts = pitchers.components(separatedBy: " vs ") - return parts.count > 1 ? parts.last : nil + private var stadiumImageURL: URL? { + TeamAssets.stadiumURL(for: game.homeTeam.code) } var body: some View { Button(action: onSelect) { - VStack(spacing: 0) { - // Full-bleed matchup area - ZStack { - heroBackground + ZStack(alignment: .leading) { + // Stadium photo background + stadiumBackground - VStack(spacing: heroSpacing) { - // Status + game type - HStack { - Text((game.gameType ?? "Featured").uppercased()) - .font(labelFont) - .foregroundStyle(.white.opacity(0.5)) - .kerning(1.5) - - Spacer() - - statusChip - } - - // Main matchup: Away — Score — Home - HStack(spacing: 0) { - teamSide( - team: game.awayTeam, - color: awayColor, - pitcherURL: game.awayPitcherHeadshotURL, - pitcherName: awayPitcherName, - alignment: .leading - ) - .frame(maxWidth: .infinity) - - scoreCenter - .frame(width: scoreCenterWidth) - - teamSide( - team: game.homeTeam, - color: homeColor, - pitcherURL: game.homePitcherHeadshotURL, - pitcherName: homePitcherName, - alignment: .trailing - ) - .frame(maxWidth: .infinity) - } - - // Subtitle line: venue + coverage - subtitleRow - } - .padding(.horizontal, heroPadH) - .padding(.vertical, heroPadV) + // Left gradient overlay for text readability + HStack(spacing: 0) { + LinearGradient( + colors: [ + Color(red: 0.08, green: 0.08, blue: 0.10), + Color(red: 0.08, green: 0.08, blue: 0.10).opacity(0.95), + Color(red: 0.08, green: 0.08, blue: 0.10).opacity(0.6), + .clear + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(maxWidth: .infinity) } - // Linescore bar (if game started) - if let linescore = game.linescore, !game.status.isScheduled { - LinescoreView( - linescore: linescore, - awayCode: game.awayTeam.code, - homeCode: game.homeTeam.code - ) - .padding(.horizontal, heroPadH) - .padding(.vertical, 16) - .background(.black.opacity(0.3)) + // Content overlay + HStack(alignment: .top, spacing: 0) { + heroContent + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, heroPadH) + .padding(.vertical, heroPadV) + + Spacer(minLength: 0) } } - .clipShape(RoundedRectangle(cornerRadius: heroRadius, style: .continuous)) + .frame(height: heroHeight) + .clipShape(RoundedRectangle(cornerRadius: DS.Radii.featured, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: heroRadius, style: .continuous) - .strokeBorder(borderColor, lineWidth: game.isLive ? 2 : 1) + RoundedRectangle(cornerRadius: DS.Radii.featured, style: .continuous) + .strokeBorder(game.isLive ? DS.Colors.live.opacity(0.3) : .white.opacity(0.1), lineWidth: game.isLive ? 2 : 1) ) - .shadow(color: game.isLive ? .red.opacity(0.2) : .black.opacity(0.3), radius: 24, y: 10) + .shadow(color: .black.opacity(0.2), radius: 30, y: 12) } .platformCardStyle() } - // MARK: - Team Side + // MARK: - Hero Content (left side overlay) @ViewBuilder - private func teamSide( - team: TeamInfo, - color: Color, - pitcherURL: URL?, - pitcherName: String?, - alignment: HorizontalAlignment - ) -> some View { - VStack(alignment: alignment, spacing: teamInfoGap) { - TeamLogoView(team: team, size: logoSize) + private var heroContent: some View { + VStack(alignment: .leading, spacing: heroContentSpacing) { + // Status badge + statusBadge - VStack(alignment: alignment, spacing: 4) { - Text(team.code) - .font(teamCodeFont) - .foregroundStyle(.white) + // Giant matchup title + VStack(alignment: .leading, spacing: 4) { + Text("\(game.awayTeam.code) vs \(game.homeTeam.code)") + .font(matchupFont) + .foregroundStyle(DS.Colors.onDarkPrimary) - Text(team.displayName) - .font(teamNameFont) - .foregroundStyle(.white.opacity(0.8)) - .lineLimit(1) + Text("\(game.awayTeam.displayName) at \(game.homeTeam.displayName)") + .font(subtitleFont) + .foregroundStyle(DS.Colors.onDarkSecondary) } - if let record = team.record { - HStack(spacing: 6) { - Text(record) - .font(recordFont) - .foregroundStyle(.white.opacity(0.6)) - - if let streak = team.streak { - Text(streak) - .font(recordFont) - .foregroundStyle(streak.hasPrefix("W") ? DS.Colors.positive : DS.Colors.live) - } - } + // Live data OR scheduled/final data + if game.isLive { + liveDataSection + } else if game.isFinal { + finalDataSection + } else { + scheduledDataSection } - if let name = pitcherName { - VStack(alignment: alignment, spacing: 2) { - Text("PROBABLE") - .font(probableLabelFont) - .foregroundStyle(.white.opacity(0.35)) - .kerning(1) - HStack(spacing: 6) { - if let url = pitcherURL { - PitcherHeadshotView(url: url, teamCode: team.code, name: nil, size: pitcherHeadshotSize) - } - Text(name) - .font(pitcherNameFont) - .foregroundStyle(.white.opacity(0.8)) - .lineLimit(1) - } + // Metadata line + metadataLine + + // CTA + HStack(spacing: 14) { + if game.hasStreams { + Label("Watch Now", systemImage: "play.fill") + .font(ctaFont) + .foregroundStyle(.white) + .padding(.horizontal, ctaPadH) + .padding(.vertical, ctaPadV) + .background(DS.Colors.interactive) + .clipShape(Capsule()) } } } } - // MARK: - Score Center + // MARK: - Live Data @ViewBuilder - private var scoreCenter: some View { - VStack(spacing: 8) { - if let away = game.awayTeam.score, let home = game.homeTeam.score { - Text("\(away) - \(home)") - .font(mainScoreFont) - .foregroundStyle(.white) - .monospacedDigit() - .contentTransition(.numericText()) - } else { - Text(game.status.label) - .font(statusTimeFont) - .foregroundStyle(.white) - } + private var liveDataSection: some View { + VStack(alignment: .leading, spacing: 16) { + // Score + HStack(alignment: .firstTextBaseline, spacing: 20) { + if let away = game.awayTeam.score, let home = game.homeTeam.score { + Text("\(away) - \(home)") + .font(liveScoreFont) + .foregroundStyle(DS.Colors.onDarkPrimary) + .monospacedDigit() + .contentTransition(.numericText()) + } - if case .live = game.status { if let inning = game.currentInningDisplay { Text(inning) .font(inningFont) .foregroundStyle(DS.Colors.live) } + } - // Count + outs for live games - if let linescore = game.linescore { - DiamondView( - balls: linescore.balls ?? 0, - strikes: linescore.strikes ?? 0, - outs: linescore.outs ?? 0 - ) + // Count + outs diamond + if let linescore = game.linescore { + DiamondView( + balls: linescore.balls ?? 0, + strikes: linescore.strikes ?? 0, + outs: linescore.outs ?? 0 + ) + } + } + } + + // MARK: - Final Data + + @ViewBuilder + private var finalDataSection: some View { + if let away = game.awayTeam.score, let home = game.homeTeam.score { + HStack(alignment: .firstTextBaseline, spacing: 16) { + Text("\(away) - \(home)") + .font(liveScoreFont) + .foregroundStyle(DS.Colors.onDarkPrimary) + .monospacedDigit() + + Text("FINAL") + .font(inningFont) + .foregroundStyle(DS.Colors.onDarkTertiary) + } + } + } + + // MARK: - Scheduled Data + + @ViewBuilder + private var scheduledDataSection: some View { + VStack(alignment: .leading, spacing: 8) { + if let pitchers = game.pitchers { + HStack(spacing: 10) { + if let url = game.awayPitcherHeadshotURL { + PitcherHeadshotView(url: url, teamCode: game.awayTeam.code, name: nil, size: pitcherSize) + } + if let url = game.homePitcherHeadshotURL { + PitcherHeadshotView(url: url, teamCode: game.homeTeam.code, name: nil, size: pitcherSize) + } + Text(pitchers) + .font(pitcherFont) + .foregroundStyle(DS.Colors.onDarkSecondary) + .lineLimit(1) + } + } + + HStack(spacing: 16) { + if let record = game.awayTeam.record { + Text("\(game.awayTeam.code) \(record)") + .font(recordFont) + .foregroundStyle(DS.Colors.onDarkTertiary) + } + if let record = game.homeTeam.record { + Text("\(game.homeTeam.code) \(record)") + .font(recordFont) + .foregroundStyle(DS.Colors.onDarkTertiary) } } } } - // MARK: - Subtitle Row + // MARK: - Status Badge @ViewBuilder - private var subtitleRow: some View { - HStack(spacing: 16) { - if let venue = game.venue { - Label(venue, systemImage: "mappin.and.ellipse") - .font(subtitleFont) - .foregroundStyle(.white.opacity(0.5)) - .lineLimit(1) - } - - Spacer() - - if game.hasStreams { - Label("\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")", systemImage: "tv.fill") - .font(subtitleFont) - .foregroundStyle(DS.Colors.interactive.opacity(0.9)) - } else if game.isBlackedOut { - Label("Blacked Out", systemImage: "eye.slash.fill") - .font(subtitleFont) - .foregroundStyle(DS.Colors.live.opacity(0.8)) - } - } - } - - // MARK: - Status Chip - - @ViewBuilder - private var statusChip: some View { + 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(chipFont) + .font(badgeFont) .foregroundStyle(.white) } .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(DS.Colors.live.opacity(0.2)) + .padding(.vertical, 7) + .background(DS.Colors.live.opacity(0.3)) .clipShape(Capsule()) case .scheduled(let time): Text(time) - .font(chipFont) + .font(badgeFont) .foregroundStyle(.white) .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.white.opacity(0.1)) + .padding(.vertical, 7) + .background(.white.opacity(0.15)) .clipShape(Capsule()) case .final_: Text("FINAL") - .font(chipFont) + .font(badgeFont) .foregroundStyle(.white) .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.white.opacity(0.1)) + .padding(.vertical, 7) + .background(.white.opacity(0.15)) .clipShape(Capsule()) case .unknown: @@ -251,85 +233,95 @@ struct FeaturedGameCard: View { } } - // MARK: - Background + // MARK: - Metadata @ViewBuilder - private var heroBackground: some View { - ZStack { - // Base - Color(red: 0.04, green: 0.05, blue: 0.08) - - // Team color washes - HStack(spacing: 0) { - awayColor.opacity(0.25) - Color.clear - homeColor.opacity(0.25) + private var metadataLine: some View { + HStack(spacing: 14) { + if let venue = game.venue { + Label(venue, systemImage: "mappin.and.ellipse") + .font(metaFont) + .foregroundStyle(DS.Colors.onDarkTertiary) } - // Glow orbs - Circle() - .fill(awayColor.opacity(0.2)) - .frame(width: 400, height: 400) - .blur(radius: 100) - .offset(x: -300, y: -50) - - Circle() - .fill(homeColor.opacity(0.2)) - .frame(width: 400, height: 400) - .blur(radius: 100) - .offset(x: 300, y: 50) + if !game.broadcasts.isEmpty { + Text("\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")") + .font(metaFont) + .foregroundStyle(DS.Colors.onDarkTertiary) + } } } - private var borderColor: Color { - if game.isLive { return DS.Colors.live.opacity(0.35) } - if game.hasStreams { return DS.Colors.interactive.opacity(0.2) } - return .white.opacity(0.06) + // MARK: - Stadium Background + + @ViewBuilder + private var stadiumBackground: some View { + if let url = stadiumImageURL { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + fallbackBackground + default: + fallbackBackground + .redacted(reason: .placeholder) + } + } + } else { + fallbackBackground + } + } + + @ViewBuilder + private var fallbackBackground: some View { + ZStack { + Color(red: 0.08, green: 0.08, blue: 0.10) + LinearGradient( + colors: [awayColor.opacity(0.3), homeColor.opacity(0.3)], + startPoint: .leading, + endPoint: .trailing + ) + } } // MARK: - Platform Sizing #if os(tvOS) - private var heroRadius: CGFloat { 28 } - private var heroPadH: CGFloat { 50 } - private var heroPadV: CGFloat { 40 } - private var heroSpacing: CGFloat { 24 } - private var logoSize: CGFloat { 120 } - private var scoreCenterWidth: CGFloat { 280 } - private var teamInfoGap: CGFloat { 12 } - private var pitcherHeadshotSize: CGFloat { 36 } - - private var mainScoreFont: Font { .system(size: 96, weight: .black, design: .rounded) } - private var statusTimeFont: Font { .system(size: 48, weight: .black, design: .rounded) } - private var inningFont: Font { .system(size: 24, weight: .bold, design: .rounded) } - private var teamCodeFont: Font { .system(size: 38, weight: .black, design: .rounded) } - private var teamNameFont: Font { .system(size: 24, weight: .bold) } + private var heroHeight: CGFloat { 500 } + private var heroPadH: CGFloat { 60 } + private var heroPadV: CGFloat { 50 } + private var heroContentSpacing: CGFloat { 18 } + private var pitcherSize: CGFloat { 40 } + private var matchupFont: Font { .system(size: 52, weight: .black, design: .rounded) } + private var subtitleFont: Font { .system(size: 26, weight: .semibold) } + private var liveScoreFont: Font { .system(size: 72, weight: .black, design: .rounded) } + private var inningFont: Font { .system(size: 28, weight: .bold, design: .rounded) } + private var badgeFont: Font { .system(size: 22, weight: .black, design: .rounded) } + private var ctaFont: Font { .system(size: 24, weight: .bold) } + private var ctaPadH: CGFloat { 32 } + private var ctaPadV: CGFloat { 14 } + private var pitcherFont: Font { .system(size: 24, weight: .semibold) } private var recordFont: Font { .system(size: 22, weight: .bold, design: .monospaced) } - private var subtitleFont: Font { .system(size: 22, weight: .medium) } - private var chipFont: Font { .system(size: 22, weight: .black, design: .rounded) } - private var labelFont: Font { .system(size: 22, weight: .bold, design: .rounded) } - private var probableLabelFont: Font { .system(size: 18, weight: .bold, design: .rounded) } - private var pitcherNameFont: Font { .system(size: 22, weight: .semibold) } + private var metaFont: Font { .system(size: 22, weight: .medium) } #else - private var heroRadius: CGFloat { 22 } - private var heroPadH: CGFloat { 24 } - private var heroPadV: CGFloat { 24 } - private var heroSpacing: CGFloat { 16 } - private var logoSize: CGFloat { 64 } - private var scoreCenterWidth: CGFloat { 160 } - private var teamInfoGap: CGFloat { 8 } - private var pitcherHeadshotSize: CGFloat { 28 } - - private var mainScoreFont: Font { .system(size: 56, weight: .black, design: .rounded) } - private var statusTimeFont: Font { .system(size: 32, weight: .black, design: .rounded) } - private var inningFont: Font { .system(size: 16, weight: .bold, design: .rounded) } - private var teamCodeFont: Font { .system(size: 24, weight: .black, design: .rounded) } - private var teamNameFont: Font { .system(size: 16, weight: .bold) } - private var recordFont: Font { .system(size: 13, weight: .bold, design: .monospaced) } - private var subtitleFont: Font { .system(size: 14, weight: .medium) } - private var chipFont: Font { .system(size: 14, weight: .black, design: .rounded) } - private var labelFont: Font { .system(size: 13, weight: .bold, design: .rounded) } - private var probableLabelFont: Font { .system(size: 11, weight: .bold, design: .rounded) } - private var pitcherNameFont: Font { .system(size: 14, weight: .semibold) } + private var heroHeight: CGFloat { 320 } + private var heroPadH: CGFloat { 28 } + private var heroPadV: CGFloat { 28 } + private var heroContentSpacing: CGFloat { 12 } + private var pitcherSize: CGFloat { 30 } + private var matchupFont: Font { .system(size: 32, weight: .black, design: .rounded) } + private var subtitleFont: Font { .system(size: 16, weight: .semibold) } + private var liveScoreFont: Font { .system(size: 48, weight: .black, design: .rounded) } + private var inningFont: Font { .system(size: 18, weight: .bold, design: .rounded) } + private var badgeFont: Font { .system(size: 14, weight: .black, design: .rounded) } + private var ctaFont: Font { .system(size: 16, weight: .bold) } + private var ctaPadH: CGFloat { 22 } + private var ctaPadV: CGFloat { 10 } + private var pitcherFont: Font { .system(size: 15, weight: .semibold) } + private var recordFont: Font { .system(size: 14, weight: .bold, design: .monospaced) } + private var metaFont: Font { .system(size: 14, weight: .medium) } #endif } diff --git a/mlbTVOS/Views/GameCardView.swift b/mlbTVOS/Views/GameCardView.swift index 06adf27..0d0ecc2 100644 --- a/mlbTVOS/Views/GameCardView.swift +++ b/mlbTVOS/Views/GameCardView.swift @@ -16,183 +16,160 @@ struct GameCardView: View { var body: some View { Button(action: onSelect) { - HStack(spacing: 0) { + VStack(spacing: 0) { // Team color accent bar HStack(spacing: 0) { Rectangle().fill(awayColor) Rectangle().fill(homeColor) } - .frame(width: 4) - .clipShape(RoundedRectangle(cornerRadius: 2)) - .padding(.vertical, 10) + .frame(height: 4) - HStack(spacing: teamBlockSpacing) { - // Away team - teamBlock(team: game.awayTeam, isWinning: isWinning(away: true)) + 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) - // Status / Score center - VStack(spacing: 4) { - if !game.status.isScheduled, let away = game.awayTeam.score, let home = game.homeTeam.score { - Text("\(away) - \(home)") - .font(scoreFont) - .foregroundStyle(.white) - .monospacedDigit() - .contentTransition(.numericText()) - } + Spacer(minLength: 6) - statusPill + // 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 + ) } - .frame(width: scoreCenterWidth) - - // Home team - teamBlock(team: game.homeTeam, isWinning: isWinning(away: false), trailing: true) - } - .padding(.horizontal, blockPadH) - - // Mini linescore (if available) - if let linescore = game.linescore, !game.status.isScheduled { - Divider() - .frame(height: dividerHeight) - .background(.white.opacity(0.08)) - - MiniLinescoreView( - linescore: linescore, - awayCode: game.awayTeam.code, - homeCode: game.homeTeam.code - ) - .padding(.horizontal, miniLSPad) } + .padding(.horizontal, cardPadH) + .padding(.bottom, cardPadV) } - .frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .leading) - .padding(.vertical, cardPadV) - .background(cardBackground) + .frame(maxWidth: .infinity, minHeight: cardHeight, alignment: .topLeading) + .background(DS.Colors.panelFill) .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() } - // MARK: - Team Block - @ViewBuilder - private func teamBlock(team: TeamInfo, isWinning: Bool, trailing: Bool = false) -> some View { - HStack(spacing: teamInnerSpacing) { - if trailing { Spacer(minLength: 0) } - + private func teamRow(team: TeamInfo, isWinning: Bool) -> some View { + HStack(spacing: teamSpacing) { TeamLogoView(team: team, size: logoSize) - VStack(alignment: trailing ? .trailing : .leading, spacing: 2) { - Text(team.code) - .font(codeFont) - .foregroundStyle(.white) + Text(team.code) + .font(codeFont) + .foregroundStyle(DS.Colors.textPrimary) + .frame(width: codeWidth, alignment: .leading) - if let record = team.record { - HStack(spacing: 4) { - Text(record) - .font(recordFont) - .foregroundStyle(.white.opacity(0.5)) + Text(team.displayName) + .font(nameFont) + .foregroundStyle(DS.Colors.textSecondary) + .lineLimit(1) - if let streak = team.streak { - Text(streak) - .font(recordFont) - .foregroundStyle(streak.hasPrefix("W") ? DS.Colors.positive : DS.Colors.live) - } - } - } + Spacer(minLength: 4) + + if let record = team.record { + Text(record) + .font(recordFont) + .foregroundStyle(DS.Colors.textTertiary) } - if !trailing { Spacer(minLength: 0) } - } - .frame(maxWidth: .infinity) - } + if let streak = team.streak { + Text(streak) + .font(recordFont) + .foregroundStyle(streak.hasPrefix("W") ? DS.Colors.positive : DS.Colors.live) + } - // MARK: - Status + 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) + } + } + } @ViewBuilder private var statusPill: some View { switch game.status { case .live(let inning): - HStack(spacing: 5) { - Circle().fill(DS.Colors.live).frame(width: 6, height: 6) + HStack(spacing: 6) { + Circle().fill(DS.Colors.live).frame(width: 8, height: 8) Text(inning ?? "LIVE") .font(statusFont) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.live) } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(DS.Colors.live.opacity(0.18)) - .clipShape(Capsule()) case .scheduled(let time): Text(time) .font(statusFont) - .foregroundStyle(.white.opacity(0.8)) + .foregroundStyle(DS.Colors.textSecondary) case .final_: Text("FINAL") .font(statusFont) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(DS.Colors.textTertiary) case .unknown: EmptyView() } } - // MARK: - Helpers - private func isWinning(away: Bool) -> Bool { guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false } return away ? a > h : h > a } - private var cardBackground: some ShapeStyle { - Color(red: 0.06, green: 0.07, blue: 0.10) - } - private var borderColor: Color { - if inMultiView { return .green.opacity(0.35) } + if inMultiView { return DS.Colors.positive.opacity(0.5) } if game.isLive { return DS.Colors.live.opacity(0.3) } - return .white.opacity(0.06) + return DS.Colors.panelStroke } private var borderWidth: CGFloat { - inMultiView || game.isLive ? 2 : 1 + inMultiView || game.isLive ? 2 : 0.5 } - // MARK: - Platform Sizing - #if os(tvOS) - private var cardHeight: CGFloat { 120 } - private var cardPadV: CGFloat { 8 } - private var cardRadius: CGFloat { 20 } - private var logoSize: CGFloat { 40 } - private var teamBlockSpacing: CGFloat { 12 } - private var teamInnerSpacing: CGFloat { 12 } - private var blockPadH: CGFloat { 18 } - private var scoreCenterWidth: CGFloat { 140 } - private var dividerHeight: CGFloat { 60 } - private var miniLSPad: CGFloat { 16 } - private var scoreFont: Font { .system(size: 30, weight: .black, design: .rounded) } - private var codeFont: Font { .system(size: 24, weight: .black, design: .rounded) } - private var recordFont: Font { .system(size: 18, weight: .bold, design: .monospaced) } - private var statusFont: Font { .system(size: 18, weight: .bold, design: .rounded) } + 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) } #else - private var cardHeight: CGFloat { 90 } - private var cardPadV: CGFloat { 6 } - private var cardRadius: CGFloat { 16 } - private var logoSize: CGFloat { 30 } - private var teamBlockSpacing: CGFloat { 8 } - private var teamInnerSpacing: CGFloat { 8 } - private var blockPadH: CGFloat { 12 } - private var scoreCenterWidth: CGFloat { 100 } - private var dividerHeight: CGFloat { 44 } - private var miniLSPad: CGFloat { 10 } - private var scoreFont: Font { .system(size: 22, weight: .black, design: .rounded) } - private var codeFont: Font { .system(size: 17, weight: .black, design: .rounded) } - private var recordFont: Font { .system(size: 12, weight: .bold, design: .monospaced) } - private var statusFont: Font { .system(size: 13, weight: .bold, design: .rounded) } + 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 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) } #endif } diff --git a/mlbTVOS/Views/LeagueCenterView.swift b/mlbTVOS/Views/LeagueCenterView.swift index ec875fd..779a484 100644 --- a/mlbTVOS/Views/LeagueCenterView.swift +++ b/mlbTVOS/Views/LeagueCenterView.swift @@ -88,11 +88,11 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 8) { Text("Around MLB") .font(.system(size: 42, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) Text("Standings, league leaders, team context, roster access, and player snapshots in one control room.") .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.white.opacity(0.58)) + .foregroundStyle(DS.Colors.textTertiary) } Spacer() @@ -111,11 +111,11 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 6) { Text("Schedule") .font(.system(size: 30, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) Text(viewModel.displayDateString) .font(.system(size: 16, weight: .medium)) - .foregroundStyle(.white.opacity(0.55)) + .foregroundStyle(DS.Colors.textTertiary) } Spacer() @@ -162,7 +162,7 @@ struct LeagueCenterView: View { VStack(spacing: 6) { Text(scoreText(for: game)) .font(.system(size: 28, weight: .black, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) .monospacedDigit() Text(statusText(for: game)) @@ -178,13 +178,13 @@ struct LeagueCenterView: View { if let venue = game.venue?.name { Text(venue) .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.white.opacity(0.56)) + .foregroundStyle(DS.Colors.textTertiary) .lineLimit(1) } Text(linkedGame != nil ? "Open game sheet" : "Info only") .font(.system(size: 13, weight: .bold, design: .rounded)) - .foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34)) + .foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : DS.Colors.textQuaternary) } .frame(width: scheduleVenueColWidth, alignment: .trailing) } @@ -222,17 +222,17 @@ struct LeagueCenterView: View { VStack(alignment: alignTrailing ? .trailing : .leading, spacing: 6) { Text(info.code) .font(.system(size: 22, weight: .black, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) Text(info.displayName) .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(DS.Colors.textSecondary) .lineLimit(1) if let record = info.record { Text(record) .font(.system(size: 13, weight: .bold, design: .monospaced)) - .foregroundStyle(.white.opacity(0.46)) + .foregroundStyle(DS.Colors.textTertiary) } } @@ -248,7 +248,7 @@ struct LeagueCenterView: View { HStack { Text("Leaders") .font(.system(size: 30, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) Spacer() @@ -274,7 +274,7 @@ struct LeagueCenterView: View { HStack { Text("League Leaders") .font(.system(size: 30, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) Spacer() @@ -310,7 +310,7 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 18) { Text("Standings") .font(.system(size: 30, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) if viewModel.standings.isEmpty && viewModel.isLoadingOverview { loadingPanel(title: "Loading standings...") @@ -335,31 +335,31 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 14) { Text(record.division?.name ?? "Division") .font(.system(size: 22, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) VStack(spacing: 10) { ForEach(record.teamRecords.sorted(by: standingsSort), id: \.team.id) { team in HStack(spacing: 10) { Text(team.divisionRank ?? "-") .font(.system(size: 12, weight: .black, design: .rounded)) - .foregroundStyle(.white.opacity(0.52)) + .foregroundStyle(DS.Colors.textTertiary) .frame(width: 22) Text(team.team.abbreviation ?? team.team.name ?? "MLB") .font(.system(size: 16, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) Spacer() if let wins = team.wins, let losses = team.losses { Text("\(wins)-\(losses)") .font(.system(size: 15, weight: .bold, design: .monospaced)) - .foregroundStyle(.white.opacity(0.86)) + .foregroundStyle(DS.Colors.textPrimary) } Text(team.gamesBack ?? "-") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.white.opacity(0.45)) + .foregroundStyle(DS.Colors.textTertiary) .frame(width: 44, alignment: .trailing) } .padding(.vertical, 4) @@ -374,7 +374,7 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 18) { Text("Teams") .font(.system(size: 30, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) ScrollView(.horizontal) { HStack(spacing: 16) { @@ -388,11 +388,11 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 6) { Text(team.abbreviation) .font(.system(size: 20, weight: .black, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) Text(team.name) .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.white.opacity(0.76)) + .foregroundStyle(DS.Colors.textSecondary) .lineLimit(2) } @@ -400,7 +400,7 @@ struct LeagueCenterView: View { Text(team.recordText ?? "Season") .font(.system(size: 13, weight: .bold, design: .monospaced)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(DS.Colors.textTertiary) } .frame(width: 210, height: 220, alignment: .leading) .padding(18) @@ -410,7 +410,7 @@ struct LeagueCenterView: View { ) .overlay( RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(.white.opacity(0.08), lineWidth: 1) + .stroke(DS.Colors.panelStroke, lineWidth: 0.5) ) } .platformCardStyle() @@ -427,7 +427,7 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 18) { Text("Team Profile") .font(.system(size: 30, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) if viewModel.isLoadingTeam { loadingPanel(title: "Loading team profile...") @@ -438,7 +438,7 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 12) { Text(team.name) .font(.system(size: 34, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) HStack(spacing: 10) { detailChip(team.recordText ?? "Season", color: .blue) @@ -472,7 +472,7 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 18) { Text("Roster") .font(.system(size: 30, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) LazyVGrid(columns: rosterColumns, spacing: 14) { ForEach(viewModel.roster) { player in @@ -485,12 +485,12 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 6) { Text(player.fullName) .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) .lineLimit(2) Text("\(player.positionAbbreviation ?? "P") · #\(player.jerseyNumber ?? "--")") .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(DS.Colors.textTertiary) } Spacer() @@ -508,7 +508,7 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 18) { Text("Player Profile") .font(.system(size: 30, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) if viewModel.isLoadingPlayer { loadingPanel(title: "Loading player profile...") @@ -522,7 +522,7 @@ struct LeagueCenterView: View { if let primaryNumber = player.primaryNumber { Text("#\(primaryNumber)") .font(.system(size: 15, weight: .black, design: .rounded)) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(DS.Colors.textSecondary) } if let position = player.primaryPosition { @@ -532,7 +532,7 @@ struct LeagueCenterView: View { Text(player.fullName) .font(.system(size: 34, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) VStack(alignment: .leading, spacing: 8) { profileLine(label: "Age", value: player.currentAge.map(String.init)) @@ -552,20 +552,20 @@ struct LeagueCenterView: View { VStack(alignment: .leading, spacing: 14) { Text("\(group.title) \(player.seasonLabel)") .font(.system(size: 18, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) VStack(alignment: .leading, spacing: 10) { ForEach(group.items, id: \.label) { item in HStack { Text(item.label) .font(.system(size: 13, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(DS.Colors.textTertiary) Spacer() Text(item.value) .font(.system(size: 18, weight: .black, design: .rounded)) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.textPrimary) } } } @@ -574,14 +574,14 @@ struct LeagueCenterView: View { .padding(18) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.white.opacity(0.05)) + .fill(DS.Colors.panelStroke) ) } } } else { Text("No regular-season MLB stats available for \(player.seasonLabel).") .font(.system(size: 18, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.7)) + .foregroundStyle(DS.Colors.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 4) } @@ -604,10 +604,10 @@ struct LeagueCenterView: View { default: ZStack { Circle() - .fill(.white.opacity(0.08)) + .fill(DS.Colors.panelStroke) Image(systemName: "person.fill") .font(.system(size: size * 0.34, weight: .bold)) - .foregroundStyle(.white.opacity(0.32)) + .foregroundStyle(DS.Colors.textQuaternary) } } } @@ -615,7 +615,7 @@ struct LeagueCenterView: View { .clipShape(Circle()) .overlay( Circle() - .stroke(.white.opacity(0.08), lineWidth: 1) + .stroke(DS.Colors.panelStroke, lineWidth: 0.5) ) } @@ -630,12 +630,12 @@ struct LeagueCenterView: View { return HStack(spacing: 10) { Text(label) .font(.system(size: 13, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.45)) + .foregroundStyle(DS.Colors.textTertiary) .frame(width: 92, alignment: .leading) Text(displayValue) .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.white.opacity(0.82)) + .foregroundStyle(DS.Colors.textPrimary) } } @@ -666,7 +666,7 @@ struct LeagueCenterView: View { .monospacedDigit() Text(label) .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white.opacity(0.5)) + .foregroundStyle(DS.Colors.textTertiary) } .padding(.horizontal, 18) .padding(.vertical, 12) @@ -681,10 +681,10 @@ struct LeagueCenterView: View { Text(title) .font(.system(size: 14, weight: .semibold)) } - .foregroundStyle(.white.opacity(0.84)) + .foregroundStyle(DS.Colors.textPrimary) .padding(.horizontal, 14) .padding(.vertical, 10) - .background(.white.opacity(0.08)) + .background(DS.Colors.panelStroke) .clipShape(Capsule()) } .platformCardStyle() @@ -695,7 +695,7 @@ struct LeagueCenterView: View { Spacer() ProgressView(title) .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.white.opacity(0.75)) + .foregroundStyle(DS.Colors.textSecondary) Spacer() } .padding(.vertical, 34) @@ -726,7 +726,7 @@ struct LeagueCenterView: View { return .red.opacity(0.9) } if game.isFinal { - return .white.opacity(0.72) + return DS.Colors.textSecondary } return .blue.opacity(0.9) } @@ -737,36 +737,15 @@ struct LeagueCenterView: View { private var sectionPanel: some View { RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(.black.opacity(0.22)) + .fill(DS.Colors.panelFill) + .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) .overlay { RoundedRectangle(cornerRadius: 24, style: .continuous) - .strokeBorder(.white.opacity(0.08), lineWidth: 1) + .strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5) } } private var screenBackground: some View { - ZStack { - LinearGradient( - colors: [ - Color(red: 0.04, green: 0.05, blue: 0.09), - Color(red: 0.03, green: 0.06, blue: 0.1), - Color(red: 0.02, green: 0.03, blue: 0.06), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - - Circle() - .fill(.blue.opacity(0.18)) - .frame(width: 520, height: 520) - .blur(radius: 110) - .offset(x: -360, y: -260) - - Circle() - .fill(.orange.opacity(0.16)) - .frame(width: 560, height: 560) - .blur(radius: 120) - .offset(x: 420, y: -80) - } + DS.Colors.background } }