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