Fix hero to match reference: white surface, image right, outlined CTA

Completely rebuilt FeaturedGameCard to match the Dribbble vtv reference.
White/cream background surface instead of dark card. Stadium image sits
on the right side and fades into white via left-to-right gradient. Dark
text on light background. "Watch Now" as outlined orange pill (not
filled). Plus icon for add-to-multiview.

Title uses split weight: thin "Houston Astros vs" + bold orange "Seattle
Mariners" — mimicking the "modern family" typography split.

Cleaned up DashboardView header: removed bulky MLB/date/stat pills
section. Replaced with compact inline date nav: chevron left, date text,
chevron right, "Today" link, game count + live count on the right. One
line instead of a full section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-12 16:17:20 -05:00
parent 65ad41840f
commit 346557af88
2 changed files with 208 additions and 225 deletions

View File

@@ -371,77 +371,71 @@ struct DashboardView: View {
@ViewBuilder @ViewBuilder
private var headerSection: some View { private var headerSection: some View {
VStack(spacing: 24) { HStack(alignment: .center) {
HStack(alignment: .bottom) { // Date navigation compact inline
VStack(alignment: .leading, spacing: 6) { HStack(spacing: 12) {
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) {
Button { Button {
Task { await viewModel.goToPreviousDay() } Task { await viewModel.goToPreviousDay() }
} label: { } 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 { if !viewModel.isToday {
Button { Button {
Task { await viewModel.goToToday() } Task { await viewModel.goToToday() }
} label: { } label: {
Label("Today", systemImage: "calendar") Text("Today")
} .font(todayBtnFont)
.tint(.blue) .foregroundStyle(DS.Colors.interactive)
}
Button {
Task { await viewModel.goToNextDay() }
} label: {
HStack(spacing: 6) {
Text("Next Day")
Image(systemName: "chevron.right")
} }
} }
}
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 #if os(tvOS)
private func statPill(_ value: String, label: String, color: Color = DS.Colors.interactive) -> some View { private var dateFont: Font { .system(size: 32, weight: .bold) }
VStack(spacing: 2) { private var dateNavIconSize: CGFloat { 22 }
Text(value) private var todayBtnFont: Font { .system(size: 22, weight: .bold, design: .rounded) }
.font(DS.Fonts.dataValue) private var metaCountFont: Font { .system(size: 22, weight: .medium) }
.foregroundStyle(color) #else
Text(label) private var dateFont: Font { .system(size: 22, weight: .bold) }
.dataLabelStyle() private var dateNavIconSize: CGFloat { 16 }
} private var todayBtnFont: Font { .system(size: 15, weight: .bold, design: .rounded) }
.padding(.horizontal, 20) private var metaCountFont: Font { .system(size: 14, weight: .medium) }
.padding(.vertical, 10) #endif
.background(DS.Colors.panelFill)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
}
// MARK: - Featured Channels // MARK: - Featured Channels

View File

@@ -21,103 +21,107 @@ struct FeaturedGameCard: View {
var body: some View { var body: some View {
Button(action: onSelect) { Button(action: onSelect) {
ZStack(alignment: .leading) { ZStack(alignment: .topLeading) {
// Stadium photo background // White/cream base
stadiumBackground DS.Colors.panelFill
// Left gradient overlay for text readability // Stadium image on the right side, fading into white on the left
HStack(spacing: 0) { HStack(spacing: 0) {
LinearGradient( Spacer()
colors: [ ZStack(alignment: .leading) {
Color(red: 0.08, green: 0.08, blue: 0.10), stadiumImage
Color(red: 0.08, green: 0.08, blue: 0.10).opacity(0.95), .frame(width: imageWidth)
Color(red: 0.08, green: 0.08, blue: 0.10).opacity(0.6),
.clear // White fade from left edge of image
], LinearGradient(
startPoint: .leading, colors: [
endPoint: .trailing DS.Colors.panelFill,
) DS.Colors.panelFill.opacity(0.8),
.frame(maxWidth: .infinity) DS.Colors.panelFill.opacity(0.3),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: fadeWidth)
}
} }
// Content overlay // Text content on the left
HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: contentSpacing) {
heroContent // Status badge
.frame(maxWidth: .infinity, alignment: .leading) statusBadge
.padding(.horizontal, heroPadH)
.padding(.vertical, heroPadV)
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) .frame(height: heroHeight)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.featured, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: heroRadius, style: .continuous))
.overlay( .shadow(color: .black.opacity(0.08), radius: 30, y: 12)
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)
} }
.platformCardStyle() .platformCardStyle()
} }
// MARK: - Hero Content (left side overlay) // MARK: - Live Section
@ViewBuilder @ViewBuilder
private var heroContent: some View { private var liveSection: some View {
VStack(alignment: .leading, spacing: heroContentSpacing) { VStack(alignment: .leading, spacing: 12) {
// Status badge HStack(alignment: .firstTextBaseline, spacing: 16) {
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) {
if let away = game.awayTeam.score, let home = game.homeTeam.score { if let away = game.awayTeam.score, let home = game.homeTeam.score {
Text("\(away) - \(home)") Text("\(away) - \(home)")
.font(liveScoreFont) .font(scoreFont)
.foregroundStyle(DS.Colors.onDarkPrimary) .foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit() .monospacedDigit()
.contentTransition(.numericText()) .contentTransition(.numericText())
} }
@@ -129,7 +133,6 @@ struct FeaturedGameCard: View {
} }
} }
// Count + outs diamond
if let linescore = game.linescore { if let linescore = game.linescore {
DiamondView( DiamondView(
balls: linescore.balls ?? 0, balls: linescore.balls ?? 0,
@@ -140,55 +143,33 @@ struct FeaturedGameCard: View {
} }
} }
// MARK: - Final Data // MARK: - Final Section
@ViewBuilder @ViewBuilder
private var finalDataSection: some View { private var finalSection: some View {
if let away = game.awayTeam.score, let home = game.homeTeam.score { if let away = game.awayTeam.score, let home = game.homeTeam.score {
HStack(alignment: .firstTextBaseline, spacing: 16) { HStack(spacing: 12) {
Text("\(away) - \(home)") Text("\(away) - \(home)")
.font(liveScoreFont) .font(scoreFont)
.foregroundStyle(DS.Colors.onDarkPrimary) .foregroundStyle(DS.Colors.textPrimary)
.monospacedDigit() .monospacedDigit()
Text("FINAL") Text("FINAL")
.font(inningFont) .font(inningFont)
.foregroundStyle(DS.Colors.onDarkTertiary) .foregroundStyle(DS.Colors.textTertiary)
} }
} }
} }
// MARK: - Scheduled Data // MARK: - Scheduled Section
@ViewBuilder @ViewBuilder
private var scheduledDataSection: some View { private var scheduledSection: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 6) {
if let pitchers = game.pitchers { if let pitchers = game.pitchers {
HStack(spacing: 10) { Text(pitchers)
if let url = game.awayPitcherHeadshotURL { .font(descFont)
PitcherHeadshotView(url: url, teamCode: game.awayTeam.code, name: nil, size: pitcherSize) .foregroundStyle(DS.Colors.textSecondary)
} .lineLimit(2)
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)
}
} }
} }
} }
@@ -203,59 +184,50 @@ struct FeaturedGameCard: View {
Circle().fill(DS.Colors.live).frame(width: 8, height: 8) Circle().fill(DS.Colors.live).frame(width: 8, height: 8)
Text(inning ?? "LIVE") Text(inning ?? "LIVE")
.font(badgeFont) .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): case .scheduled(let time):
Text(time) Text(time)
.font(badgeFont) .font(badgeFont)
.foregroundStyle(.white) .foregroundStyle(DS.Colors.textTertiary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.white.opacity(0.15))
.clipShape(Capsule())
case .final_: case .final_:
Text("FINAL") Text("FINAL")
.font(badgeFont) .font(badgeFont)
.foregroundStyle(.white) .foregroundStyle(DS.Colors.textTertiary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.white.opacity(0.15))
.clipShape(Capsule())
case .unknown: case .unknown:
EmptyView() EmptyView()
} }
} }
// MARK: - Metadata // MARK: - Metadata Line
@ViewBuilder @ViewBuilder
private var metadataLine: some View { private var metadataLine: some View {
HStack(spacing: 14) { HStack(spacing: metaSeparatorWidth) {
if let venue = game.venue { if let venue = game.venue {
Label(venue, systemImage: "mappin.and.ellipse") Text(venue)
.font(metaFont) }
.foregroundStyle(DS.Colors.onDarkTertiary) 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 { if !game.broadcasts.isEmpty {
Text("\(game.broadcasts.count) feed\(game.broadcasts.count == 1 ? "" : "s")") 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 @ViewBuilder
private var stadiumBackground: some View { private var stadiumImage: some View {
if let url = stadiumImageURL { if let url = stadiumImageURL {
AsyncImage(url: url) { phase in AsyncImage(url: url) { phase in
switch phase { switch phase {
@@ -263,65 +235,82 @@ struct FeaturedGameCard: View {
image image
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
case .failure: .frame(height: heroHeight)
fallbackBackground .clipped()
default: default:
fallbackBackground fallbackImage
.redacted(reason: .placeholder)
} }
} }
} else { } else {
fallbackBackground fallbackImage
} }
} }
@ViewBuilder @ViewBuilder
private var fallbackBackground: some View { private var fallbackImage: some View {
ZStack { ZStack {
Color(red: 0.08, green: 0.08, blue: 0.10)
LinearGradient( LinearGradient(
colors: [awayColor.opacity(0.3), homeColor.opacity(0.3)], colors: [awayColor.opacity(0.15), homeColor.opacity(0.15)],
startPoint: .leading, startPoint: .leading,
endPoint: .trailing 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 // MARK: - Platform Sizing
#if os(tvOS) #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 heroPadH: CGFloat { 60 }
private var heroPadV: CGFloat { 50 } private var heroPadV: CGFloat { 50 }
private var heroContentSpacing: CGFloat { 18 } private var contentSpacing: CGFloat { 16 }
private var pitcherSize: CGFloat { 40 } private var imageWidth: CGFloat { 900 }
private var matchupFont: Font { .system(size: 52, weight: .black, design: .rounded) } private var fadeWidth: CGFloat { 400 }
private var subtitleFont: Font { .system(size: 26, weight: .semibold) } private var textAreaWidth: CGFloat { 700 }
private var liveScoreFont: Font { .system(size: 72, weight: .black, design: .rounded) } 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 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 ctaFont: Font { .system(size: 24, weight: .bold) }
private var ctaPadH: CGFloat { 32 } private var ctaPadH: CGFloat { 32 }
private var ctaPadV: CGFloat { 14 } 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 #else
private var heroHeight: CGFloat { 320 } private var heroHeight: CGFloat { 340 }
private var heroRadius: CGFloat { 22 }
private var heroPadH: CGFloat { 28 } private var heroPadH: CGFloat { 28 }
private var heroPadV: CGFloat { 28 } private var heroPadV: CGFloat { 28 }
private var heroContentSpacing: CGFloat { 12 } private var contentSpacing: CGFloat { 10 }
private var pitcherSize: CGFloat { 30 } private var imageWidth: CGFloat { 400 }
private var matchupFont: Font { .system(size: 32, weight: .black, design: .rounded) } private var fadeWidth: CGFloat { 200 }
private var subtitleFont: Font { .system(size: 16, weight: .semibold) } private var textAreaWidth: CGFloat { 350 }
private var liveScoreFont: Font { .system(size: 48, weight: .black, design: .rounded) } 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 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 ctaFont: Font { .system(size: 16, weight: .bold) }
private var ctaPadH: CGFloat { 22 } private var ctaPadH: CGFloat { 22 }
private var ctaPadV: CGFloat { 10 } 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 #endif
} }