Complete visual overhaul: warm light theme, stadium hero, inline pill nav
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
981C95E73FFDAFE2E26B06C2 /* FeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedView.swift; sourceTree = "<group>"; };
|
||||
726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardView.swift; sourceTree = "<group>"; };
|
||||
D5381B6E058D1194E844106D /* CategoryPillBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPillBar.swift; sourceTree = "<group>"; };
|
||||
/* 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 */,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
98
mlbTVOS/Views/Components/CategoryPillBar.swift
Normal file
98
mlbTVOS/Views/Components/CategoryPillBar.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ struct DataPanel<Content: View>: 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<Content: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience initializers
|
||||
|
||||
extension DataPanel {
|
||||
init(
|
||||
_ density: DataPanelDensity = .standard,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user