diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index 1cb0c3c..f4a109b 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -371,77 +371,71 @@ struct DashboardView: View { @ViewBuilder private var headerSection: some View { - VStack(spacing: 24) { - HStack(alignment: .bottom) { - VStack(alignment: .leading, spacing: 6) { - Text("MLB") - .font(.headline.weight(.black)) - .foregroundStyle(DS.Colors.textTertiary) - .kerning(4) - Text(viewModel.displayDateString) - .font(.system(size: 40, weight: .bold)) - .foregroundStyle(DS.Colors.textPrimary) - .contentTransition(.numericText()) - } - - Spacer() - - HStack(spacing: 16) { - statPill("\(viewModel.games.count)", label: "Games") - if !viewModel.liveGames.isEmpty { - statPill("\(viewModel.liveGames.count)", label: "Live", color: .red) - } - if !viewModel.activeStreams.isEmpty { - statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green) - } - } - } - - HStack(spacing: 16) { + HStack(alignment: .center) { + // Date navigation — compact inline + HStack(spacing: 12) { Button { Task { await viewModel.goToPreviousDay() } } label: { - Label("Previous Day", systemImage: "chevron.left") + Image(systemName: "chevron.left") + .font(.system(size: dateNavIconSize, weight: .semibold)) + .foregroundStyle(DS.Colors.textTertiary) + } + + Text(viewModel.displayDateString) + .font(dateFont) + .foregroundStyle(DS.Colors.textPrimary) + .contentTransition(.numericText()) + + Button { + Task { await viewModel.goToNextDay() } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: dateNavIconSize, weight: .semibold)) + .foregroundStyle(DS.Colors.textTertiary) } if !viewModel.isToday { Button { Task { await viewModel.goToToday() } } label: { - Label("Today", systemImage: "calendar") - } - .tint(.blue) - } - - Button { - Task { await viewModel.goToNextDay() } - } label: { - HStack(spacing: 6) { - Text("Next Day") - Image(systemName: "chevron.right") + Text("Today") + .font(todayBtnFont) + .foregroundStyle(DS.Colors.interactive) } } + } - Spacer() + Spacer() + + HStack(spacing: 12) { + Text("\(viewModel.games.count) games") + .font(metaCountFont) + .foregroundStyle(DS.Colors.textTertiary) + + 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) + } + } } } } - @ViewBuilder - private func statPill(_ value: String, label: String, color: Color = DS.Colors.interactive) -> some View { - VStack(spacing: 2) { - Text(value) - .font(DS.Fonts.dataValue) - .foregroundStyle(color) - Text(label) - .dataLabelStyle() - } - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(DS.Colors.panelFill) - .clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact)) - .shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY) - } + #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) } + #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) } + #endif // MARK: - Featured Channels diff --git a/mlbTVOS/Views/FeaturedGameCard.swift b/mlbTVOS/Views/FeaturedGameCard.swift index a95f5a4..c48f3d3 100644 --- a/mlbTVOS/Views/FeaturedGameCard.swift +++ b/mlbTVOS/Views/FeaturedGameCard.swift @@ -21,103 +21,107 @@ struct FeaturedGameCard: View { var body: some View { Button(action: onSelect) { - ZStack(alignment: .leading) { - // Stadium photo background - stadiumBackground + ZStack(alignment: .topLeading) { + // White/cream base + DS.Colors.panelFill - // Left gradient overlay for text readability + // Stadium image on the right side, fading into white on the left 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) + Spacer() + ZStack(alignment: .leading) { + stadiumImage + .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) + } } - // Content overlay - HStack(alignment: .top, spacing: 0) { - heroContent - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, heroPadH) - .padding(.vertical, heroPadV) + // Text content on the left + VStack(alignment: .leading, spacing: contentSpacing) { + // Status badge + statusBadge - Spacer(minLength: 0) + // 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) + } + Text(game.homeTeam.displayName) + .font(titleBoldFont) + .foregroundStyle(DS.Colors.interactive) + } + + // 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) + ) + } } + .padding(.horizontal, heroPadH) + .padding(.vertical, heroPadV) + .frame(maxWidth: textAreaWidth, alignment: .topLeading) } .frame(height: heroHeight) - .clipShape(RoundedRectangle(cornerRadius: DS.Radii.featured, style: .continuous)) - .overlay( - 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: .black.opacity(0.2), radius: 30, y: 12) + .clipShape(RoundedRectangle(cornerRadius: heroRadius, style: .continuous)) + .shadow(color: .black.opacity(0.08), radius: 30, y: 12) } .platformCardStyle() } - // MARK: - Hero Content (left side overlay) + // MARK: - Live Section @ViewBuilder - private var heroContent: some View { - VStack(alignment: .leading, spacing: heroContentSpacing) { - // Status badge - statusBadge - - // Giant matchup title - VStack(alignment: .leading, spacing: 4) { - Text("\(game.awayTeam.code) vs \(game.homeTeam.code)") - .font(matchupFont) - .foregroundStyle(DS.Colors.onDarkPrimary) - - Text("\(game.awayTeam.displayName) at \(game.homeTeam.displayName)") - .font(subtitleFont) - .foregroundStyle(DS.Colors.onDarkSecondary) - } - - // Live data OR scheduled/final data - if game.isLive { - liveDataSection - } else if game.isFinal { - finalDataSection - } else { - scheduledDataSection - } - - // 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: - Live Data - - @ViewBuilder - private var liveDataSection: some View { - VStack(alignment: .leading, spacing: 16) { - // Score - HStack(alignment: .firstTextBaseline, spacing: 20) { + 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(liveScoreFont) - .foregroundStyle(DS.Colors.onDarkPrimary) + .font(scoreFont) + .foregroundStyle(DS.Colors.textPrimary) .monospacedDigit() .contentTransition(.numericText()) } @@ -129,7 +133,6 @@ struct FeaturedGameCard: View { } } - // Count + outs diamond if let linescore = game.linescore { DiamondView( balls: linescore.balls ?? 0, @@ -140,55 +143,33 @@ struct FeaturedGameCard: View { } } - // MARK: - Final Data + // MARK: - Final Section @ViewBuilder - private var finalDataSection: some View { + private var finalSection: some View { if let away = game.awayTeam.score, let home = game.homeTeam.score { - HStack(alignment: .firstTextBaseline, spacing: 16) { + HStack(spacing: 12) { Text("\(away) - \(home)") - .font(liveScoreFont) - .foregroundStyle(DS.Colors.onDarkPrimary) + .font(scoreFont) + .foregroundStyle(DS.Colors.textPrimary) .monospacedDigit() - Text("FINAL") .font(inningFont) - .foregroundStyle(DS.Colors.onDarkTertiary) + .foregroundStyle(DS.Colors.textTertiary) } } } - // MARK: - Scheduled Data + // MARK: - Scheduled Section @ViewBuilder - private var scheduledDataSection: some View { - VStack(alignment: .leading, spacing: 8) { + private var scheduledSection: some View { + VStack(alignment: .leading, spacing: 6) { 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) - } + Text(pitchers) + .font(descFont) + .foregroundStyle(DS.Colors.textSecondary) + .lineLimit(2) } } } @@ -203,59 +184,50 @@ struct FeaturedGameCard: View { Circle().fill(DS.Colors.live).frame(width: 8, height: 8) Text(inning ?? "LIVE") .font(badgeFont) - .foregroundStyle(.white) + .foregroundStyle(DS.Colors.live) } - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(DS.Colors.live.opacity(0.3)) - .clipShape(Capsule()) case .scheduled(let time): Text(time) .font(badgeFont) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(.white.opacity(0.15)) - .clipShape(Capsule()) + .foregroundStyle(DS.Colors.textTertiary) case .final_: Text("FINAL") .font(badgeFont) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(.white.opacity(0.15)) - .clipShape(Capsule()) + .foregroundStyle(DS.Colors.textTertiary) case .unknown: EmptyView() } } - // MARK: - Metadata + // MARK: - Metadata Line @ViewBuilder private var metadataLine: some View { - HStack(spacing: 14) { + HStack(spacing: metaSeparatorWidth) { if let venue = game.venue { - Label(venue, systemImage: "mappin.and.ellipse") - .font(metaFont) - .foregroundStyle(DS.Colors.onDarkTertiary) + 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.onDarkTertiary) } } + .font(metaFont) + .foregroundStyle(DS.Colors.textTertiary) } - // MARK: - Stadium Background + // MARK: - Stadium Image @ViewBuilder - private var stadiumBackground: some View { + private var stadiumImage: some View { if let url = stadiumImageURL { AsyncImage(url: url) { phase in switch phase { @@ -263,65 +235,82 @@ struct FeaturedGameCard: View { image .resizable() .aspectRatio(contentMode: .fill) - case .failure: - fallbackBackground + .frame(height: heroHeight) + .clipped() default: - fallbackBackground - .redacted(reason: .placeholder) + fallbackImage } } } else { - fallbackBackground + fallbackImage } } @ViewBuilder - private var fallbackBackground: some View { + private var fallbackImage: some View { ZStack { - Color(red: 0.08, green: 0.08, blue: 0.10) LinearGradient( - colors: [awayColor.opacity(0.3), homeColor.opacity(0.3)], + colors: [awayColor.opacity(0.15), homeColor.opacity(0.15)], startPoint: .leading, endPoint: .trailing ) + + HStack(spacing: fallbackLogoGap) { + TeamLogoView(team: game.awayTeam, size: fallbackLogoSize) + .opacity(0.4) + TeamLogoView(team: game.homeTeam, size: fallbackLogoSize) + .opacity(0.4) + } } } // MARK: - Platform Sizing #if os(tvOS) - private var heroHeight: CGFloat { 500 } + private var heroHeight: CGFloat { 480 } + private var heroRadius: CGFloat { 28 } 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 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: .black, 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 pitcherFont: Font { .system(size: 24, weight: .semibold) } - private var recordFont: Font { .system(size: 22, weight: .bold, design: .monospaced) } - private var metaFont: Font { .system(size: 22, weight: .medium) } #else - private var heroHeight: CGFloat { 320 } + private var heroHeight: CGFloat { 340 } + private var heroRadius: CGFloat { 22 } 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 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: .black, 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 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 }