Add UI redesign: design system, dashboard density, game intelligence, feed tab, league leaders

Phase 1 - Design System: DesignSystem.swift (typography, colors, spacing
constants) and DataPanel.swift (reusable panel container with 3 densities
and optional team accent bar).

Phase 2 - Dashboard Density: LiveSituationBar (compact strip of all live
games with scores/innings/outs), MiniLinescoreView (R-H-E footer for game
cards), DiamondView (visual baseball diamond with runners and count).
Dashboard shows live situation bar when games are active. Game cards now
display mini linescore for live/final games.

Phase 3 - Game Center Intelligence: WinProbabilityChartView (full-game
line chart using Swift Charts with area fills), PitchArsenalView (pitch
type distribution with velocity bars). GameCenterViewModel now stores
full WP history array instead of just latest values.

Phase 4 - Feed Tab: MLBWebDataService (fetches league leaders from Stats
API, news headlines, transactions), FeedViewModel, FeedView with
reverse-chronological feed items. FeedItemView with colored edge bars
by category. Added 5th "Feed" tab to both tvOS and iOS.

Phase 5 - Intel Tab: LeaderboardView (top-5 stat cards with headshots),
integrated into LeagueCenterView. Renamed tabs: Games->Today,
League->Intel. LeagueCenterViewModel now fetches league leaders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-12 13:12:09 -05:00
parent ba24c767a0
commit b5daddefd3
21 changed files with 1469 additions and 17 deletions

View File

@@ -0,0 +1,69 @@
import SwiftUI
enum DataPanelDensity {
case compact
case standard
case featured
var padding: CGFloat {
switch self {
case .compact: DS.Spacing.panelPadCompact
case .standard: DS.Spacing.panelPadStandard
case .featured: DS.Spacing.panelPadFeatured
}
}
var cornerRadius: CGFloat {
switch self {
case .compact: DS.Radii.compact
case .standard: DS.Radii.standard
case .featured: DS.Radii.featured
}
}
}
struct DataPanel<Content: View>: View {
let density: DataPanelDensity
var teamAccentCode: String? = nil
@ViewBuilder let content: () -> Content
var body: some View {
HStack(spacing: 0) {
if let code = teamAccentCode {
RoundedRectangle(cornerRadius: 1.5)
.fill(TeamAssets.color(for: code))
.frame(width: 3)
.padding(.vertical, 6)
.padding(.leading, 4)
}
VStack(alignment: .leading, spacing: 0) {
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(density.padding)
}
.background(
RoundedRectangle(cornerRadius: density.cornerRadius)
.fill(DS.Colors.panelFill)
)
.overlay(
RoundedRectangle(cornerRadius: density.cornerRadius)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
}
}
// MARK: - Convenience initializers
extension DataPanel {
init(
_ density: DataPanelDensity = .standard,
teamAccent: String? = nil,
@ViewBuilder content: @escaping () -> Content
) {
self.density = density
self.teamAccentCode = teamAccent
self.content = content
}
}

View File

@@ -0,0 +1,101 @@
import SwiftUI
// MARK: - The Dugout Design System
enum DS {
// MARK: - Colors
enum Colors {
static let background = Color(red: 0.02, green: 0.03, blue: 0.05)
static let panelFill = Color.white.opacity(0.04)
static let panelStroke = Color.white.opacity(0.06)
static let live = Color(red: 0.95, green: 0.22, blue: 0.22)
static let positive = Color(red: 0.20, green: 0.78, blue: 0.35)
static let warning = Color(red: 0.95, green: 0.55, blue: 0.15)
static let interactive = Color(red: 0.25, green: 0.52, blue: 0.95)
static let media = Color(red: 0.55, green: 0.35, blue: 0.85)
static let textPrimary = Color.white
static let textSecondary = Color.white.opacity(0.7)
static let textTertiary = Color.white.opacity(0.45)
static let textQuaternary = Color.white.opacity(0.2)
}
// MARK: - Typography
enum Fonts {
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 score = Font.system(size: 28, weight: .black, 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 cardTitle = Font.system(size: 20, weight: .bold, design: .rounded)
static let cardTitleCompact = Font.system(size: 17, weight: .bold, design: .rounded)
static let dataValue = Font.system(size: 18, weight: .bold, design: .rounded).monospacedDigit()
static let dataValueCompact = Font.system(size: 15, weight: .semibold, design: .rounded).monospacedDigit()
static let body = Font.system(size: 15, weight: .medium)
static let bodySmall = Font.system(size: 13, weight: .medium)
static let caption = Font.system(size: 11, weight: .bold, design: .rounded)
// tvOS scaled variants
#if os(tvOS)
static let tvSectionTitle = Font.system(size: 36, weight: .bold, design: .rounded)
static let tvCardTitle = Font.system(size: 26, weight: .bold, design: .rounded)
static let tvScore = Font.system(size: 34, weight: .black, design: .rounded).monospacedDigit()
static let tvDataValue = Font.system(size: 22, weight: .bold, design: .rounded).monospacedDigit()
static let tvBody = Font.system(size: 20, weight: .medium)
static let tvCaption = Font.system(size: 15, weight: .bold, design: .rounded)
#endif
}
// MARK: - Spacing
enum Spacing {
#if os(tvOS)
static let panelPadCompact: CGFloat = 18
static let panelPadStandard: CGFloat = 24
static let panelPadFeatured: CGFloat = 32
static let sectionGap: CGFloat = 40
static let cardGap: CGFloat = 20
static let itemGap: CGFloat = 12
static let edgeInset: CGFloat = 50
#else
static let panelPadCompact: CGFloat = 12
static let panelPadStandard: CGFloat = 16
static let panelPadFeatured: CGFloat = 24
static let sectionGap: CGFloat = 28
static let cardGap: CGFloat = 14
static let itemGap: CGFloat = 8
static let edgeInset: CGFloat = 20
#endif
}
// MARK: - Radii
enum Radii {
static let compact: CGFloat = 14
static let standard: CGFloat = 18
static let featured: CGFloat = 22
}
}
// MARK: - Data Label Style
struct DataLabelStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textQuaternary)
.textCase(.uppercase)
.kerning(1.5)
}
}
extension View {
func dataLabelStyle() -> some View {
modifier(DataLabelStyle())
}
}

View File

@@ -0,0 +1,120 @@
import SwiftUI
/// Visual baseball diamond showing base runners, count, and outs
struct DiamondView: View {
var onFirst: Bool = false
var onSecond: Bool = false
var onThird: Bool = false
var balls: Int = 0
var strikes: Int = 0
var outs: Int = 0
var body: some View {
HStack(spacing: diamondCountGap) {
// Diamond
ZStack {
// Diamond shape outline
diamondPath
.stroke(DS.Colors.textQuaternary, lineWidth: 1.5)
// Base markers
baseMarker(at: firstBasePos, occupied: onFirst)
baseMarker(at: secondBasePos, occupied: onSecond)
baseMarker(at: thirdBasePos, occupied: onThird)
baseMarker(at: homePos, occupied: false, isHome: true)
}
.frame(width: diamondSize, height: diamondSize)
// Count + Outs
VStack(alignment: .leading, spacing: countSpacing) {
HStack(spacing: 3) {
Text("B").dataLabelStyle()
ForEach(0..<4, id: \.self) { i in
Circle()
.fill(i < balls ? DS.Colors.positive : DS.Colors.textQuaternary)
.frame(width: dotSize, height: dotSize)
}
}
HStack(spacing: 3) {
Text("S").dataLabelStyle()
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i < strikes ? DS.Colors.live : DS.Colors.textQuaternary)
.frame(width: dotSize, height: dotSize)
}
}
HStack(spacing: 3) {
Text("O").dataLabelStyle()
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i < outs ? DS.Colors.warning : DS.Colors.textQuaternary)
.frame(width: dotSize, height: dotSize)
}
}
}
}
}
// MARK: - Diamond geometry
private var diamondPath: Path {
let s = diamondSize
let mid = s / 2
let inset: CGFloat = baseSize / 2 + 2
var path = Path()
path.move(to: CGPoint(x: mid, y: inset)) // 2nd base (top)
path.addLine(to: CGPoint(x: s - inset, y: mid)) // 1st base (right)
path.addLine(to: CGPoint(x: mid, y: s - inset)) // Home (bottom)
path.addLine(to: CGPoint(x: inset, y: mid)) // 3rd base (left)
path.closeSubpath()
return path
}
private var firstBasePos: CGPoint {
CGPoint(x: diamondSize - baseSize / 2 - 2, y: diamondSize / 2)
}
private var secondBasePos: CGPoint {
CGPoint(x: diamondSize / 2, y: baseSize / 2 + 2)
}
private var thirdBasePos: CGPoint {
CGPoint(x: baseSize / 2 + 2, y: diamondSize / 2)
}
private var homePos: CGPoint {
CGPoint(x: diamondSize / 2, y: diamondSize - baseSize / 2 - 2)
}
@ViewBuilder
private func baseMarker(at position: CGPoint, occupied: Bool, isHome: Bool = false) -> some View {
Group {
if isHome {
// Home plate: small pentagon-like shape
Circle()
.fill(DS.Colors.textQuaternary)
.frame(width: baseSize * 0.7, height: baseSize * 0.7)
} else {
// Base: rotated square
Rectangle()
.fill(occupied ? DS.Colors.interactive : DS.Colors.textQuaternary)
.frame(width: baseSize, height: baseSize)
.rotationEffect(.degrees(45))
}
}
.position(position)
}
// MARK: - Platform sizing
#if os(tvOS)
private var diamondSize: CGFloat { 60 }
private var baseSize: CGFloat { 12 }
private var dotSize: CGFloat { 9 }
private var countSpacing: CGFloat { 5 }
private var diamondCountGap: CGFloat { 14 }
#else
private var diamondSize: CGFloat { 44 }
private var baseSize: CGFloat { 9 }
private var dotSize: CGFloat { 7 }
private var countSpacing: CGFloat { 3 }
private var diamondCountGap: CGFloat { 10 }
#endif
}

View File

@@ -0,0 +1,112 @@
import SwiftUI
struct FeedItemView: View {
let item: FeedItem
private var accentColor: Color {
switch item.type {
case .news: DS.Colors.interactive
case .transaction: DS.Colors.positive
case .scoring: DS.Colors.live
}
}
private var iconName: String {
switch item.type {
case .news: "newspaper.fill"
case .transaction: "arrow.left.arrow.right"
case .scoring: "sportscourt.fill"
}
}
private var typeLabel: String {
switch item.type {
case .news: "NEWS"
case .transaction: "TRANSACTION"
case .scoring: "SCORING"
}
}
var body: some View {
HStack(spacing: 0) {
// Colored edge bar
RoundedRectangle(cornerRadius: 1.5)
.fill(accentColor)
.frame(width: 3)
.padding(.vertical, 8)
HStack(spacing: 12) {
Image(systemName: iconName)
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(accentColor)
.frame(width: iconFrame, height: iconFrame)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(typeLabel)
.font(DS.Fonts.caption)
.foregroundStyle(accentColor)
.kerning(1)
if let code = item.teamCode {
HStack(spacing: 4) {
RoundedRectangle(cornerRadius: 1)
.fill(TeamAssets.color(for: code))
.frame(width: 2, height: 10)
Text(code)
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
}
}
Spacer()
Text(timeAgo(item.timestamp))
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textQuaternary)
}
Text(item.title)
.font(titleFont)
.foregroundStyle(DS.Colors.textPrimary)
.lineLimit(2)
if !item.subtitle.isEmpty {
Text(item.subtitle)
.font(subtitleFont)
.foregroundStyle(DS.Colors.textTertiary)
.lineLimit(1)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
.background(DS.Colors.panelFill)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
.overlay(
RoundedRectangle(cornerRadius: DS.Radii.compact)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
}
private func timeAgo(_ date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 { return "Just now" }
if interval < 3600 { return "\(Int(interval / 60))m ago" }
if interval < 86400 { return "\(Int(interval / 3600))h ago" }
return "\(Int(interval / 86400))d ago"
}
#if os(tvOS)
private var iconSize: CGFloat { 18 }
private var iconFrame: CGFloat { 36 }
private var titleFont: Font { .system(size: 18, weight: .semibold) }
private var subtitleFont: Font { DS.Fonts.bodySmall }
#else
private var iconSize: CGFloat { 14 }
private var iconFrame: CGFloat { 28 }
private var titleFont: Font { .system(size: 15, weight: .semibold) }
private var subtitleFont: Font { .system(size: 12, weight: .medium) }
#endif
}

View File

@@ -0,0 +1,80 @@
import SwiftUI
/// Top-5 stat leaderboard card with player headshots
struct LeaderboardView: View {
let category: LeaderCategory
var body: some View {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 10) {
Text(category.name.uppercased())
.dataLabelStyle()
ForEach(category.leaders) { leader in
leaderRow(leader)
}
}
}
}
@ViewBuilder
private func leaderRow(_ leader: LeagueLeader) -> some View {
HStack(spacing: 10) {
Text("\(leader.rank)")
.font(rankFont)
.foregroundStyle(leader.rank <= 3 ? DS.Colors.textPrimary : DS.Colors.textTertiary)
.frame(width: rankWidth, alignment: .center)
// Player headshot
if let personId = leader.personId {
AsyncImage(url: URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_80,q_auto:best/v1/people/\(personId)/headshot/67/current")) { phase in
if let image = phase.image {
image.resizable().aspectRatio(contentMode: .fill)
} else {
Circle().fill(DS.Colors.panelFill)
}
}
.frame(width: headshotSize, height: headshotSize)
.clipShape(Circle())
}
VStack(alignment: .leading, spacing: 1) {
Text(leader.playerName)
.font(nameFont)
.foregroundStyle(DS.Colors.textPrimary)
.lineLimit(1)
if !leader.teamCode.isEmpty {
HStack(spacing: 3) {
RoundedRectangle(cornerRadius: 1)
.fill(TeamAssets.color(for: leader.teamCode))
.frame(width: 2, height: 10)
Text(leader.teamCode)
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
}
}
}
Spacer()
Text(leader.value)
.font(valueFont)
.foregroundStyle(leader.rank == 1 ? DS.Colors.interactive : DS.Colors.textPrimary)
}
}
#if os(tvOS)
private var rankWidth: CGFloat { 28 }
private var headshotSize: CGFloat { 36 }
private var rankFont: Font { .system(size: 16, weight: .bold, design: .rounded).monospacedDigit() }
private var nameFont: Font { .system(size: 17, weight: .semibold) }
private var valueFont: Font { DS.Fonts.tvDataValue }
#else
private var rankWidth: CGFloat { 22 }
private var headshotSize: CGFloat { 28 }
private var rankFont: Font { .system(size: 13, weight: .bold, design: .rounded).monospacedDigit() }
private var nameFont: Font { .system(size: 14, weight: .semibold) }
private var valueFont: Font { DS.Fonts.dataValue }
#endif
}

View File

@@ -0,0 +1,101 @@
import SwiftUI
/// Compact horizontal strip of all live games scores, innings, outs at a glance
struct LiveSituationBar: View {
let games: [Game]
var onTapGame: ((Game) -> Void)? = nil
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: DS.Spacing.cardGap) {
ForEach(games) { game in
liveTile(game)
}
}
.padding(.horizontal, DS.Spacing.edgeInset)
}
}
@ViewBuilder
private func liveTile(_ game: Game) -> some View {
Button {
onTapGame?(game)
} label: {
DataPanel(.compact) {
HStack(spacing: tileSpacing) {
// Teams + scores
VStack(alignment: .leading, spacing: 3) {
teamScoreRow(code: game.awayTeam.code, score: game.awayTeam.score)
teamScoreRow(code: game.homeTeam.code, score: game.homeTeam.score)
}
// Situation
VStack(alignment: .trailing, spacing: 3) {
if let inning = game.currentInningDisplay ?? game.status.liveInning {
Text(inning)
.font(inningFont)
.foregroundStyle(DS.Colors.textSecondary)
}
if let linescore = game.linescore {
HStack(spacing: 2) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(i < (linescore.outs ?? 0) ? DS.Colors.warning : DS.Colors.textQuaternary)
.frame(width: outDotSize, height: outDotSize)
}
}
}
}
}
}
}
.platformCardStyle()
}
@ViewBuilder
private func teamScoreRow(code: String, score: Int?) -> some View {
HStack(spacing: 6) {
TeamLogoView(team: TeamInfo(code: code, name: "", score: nil), size: logoSize)
Text(code)
.font(teamFont.weight(.bold))
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: codeWidth, alignment: .leading)
Text(score.map { "\($0)" } ?? "-")
.font(scoreFont.weight(.black).monospacedDigit())
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: scoreWidth, alignment: .trailing)
}
}
#if os(tvOS)
private var tileSpacing: CGFloat { 20 }
private var logoSize: CGFloat { 28 }
private var codeWidth: CGFloat { 44 }
private var scoreWidth: CGFloat { 30 }
private var outDotSize: CGFloat { 8 }
private var teamFont: Font { .system(size: 18, design: .rounded) }
private var scoreFont: Font { .system(size: 20, design: .rounded) }
private var inningFont: Font { .system(size: 15, weight: .semibold, design: .rounded) }
#else
private var tileSpacing: CGFloat { 14 }
private var logoSize: CGFloat { 22 }
private var codeWidth: CGFloat { 34 }
private var scoreWidth: CGFloat { 24 }
private var outDotSize: CGFloat { 6 }
private var teamFont: Font { .system(size: 14, design: .rounded) }
private var scoreFont: Font { .system(size: 16, design: .rounded) }
private var inningFont: Font { .system(size: 12, weight: .semibold, design: .rounded) }
#endif
}
// MARK: - GameStatus helper
private extension GameStatus {
var liveInning: String? {
if case .live(let info) = self { return info }
return nil
}
}

View File

@@ -0,0 +1,52 @@
import SwiftUI
/// Compact R-H-E display for game cards
struct MiniLinescoreView: View {
let linescore: StatsLinescore
let awayCode: String
let homeCode: String
var body: some View {
HStack(spacing: 0) {
// Team abbreviations column
VStack(alignment: .leading, spacing: 2) {
Text(awayCode).foregroundStyle(TeamAssets.color(for: awayCode))
Text(homeCode).foregroundStyle(TeamAssets.color(for: homeCode))
}
.font(statFont.weight(.bold))
.frame(width: teamColWidth, alignment: .leading)
// R column
statColumn("R", away: linescore.teams?.away?.runs, home: linescore.teams?.home?.runs, bold: true)
// H column
statColumn("H", away: linescore.teams?.away?.hits, home: linescore.teams?.home?.hits, bold: false)
// E column
statColumn("E", away: linescore.teams?.away?.errors, home: linescore.teams?.home?.errors, bold: false)
}
}
@ViewBuilder
private func statColumn(_ label: String, away: Int?, home: Int?, bold: Bool) -> some View {
VStack(spacing: 2) {
Text(label)
.dataLabelStyle()
Text(away.map { "\($0)" } ?? "-")
.font(statFont.weight(bold ? .bold : .medium).monospacedDigit())
.foregroundStyle(bold ? DS.Colors.textPrimary : DS.Colors.textSecondary)
Text(home.map { "\($0)" } ?? "-")
.font(statFont.weight(bold ? .bold : .medium).monospacedDigit())
.foregroundStyle(bold ? DS.Colors.textPrimary : DS.Colors.textSecondary)
}
.frame(width: statColWidth)
}
#if os(tvOS)
private var statFont: Font { .system(size: 17, design: .rounded) }
private var teamColWidth: CGFloat { 50 }
private var statColWidth: CGFloat { 36 }
#else
private var statFont: Font { .system(size: 13, design: .rounded) }
private var teamColWidth: CGFloat { 36 }
private var statColWidth: CGFloat { 28 }
#endif
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
/// Pitch type breakdown for a pitcher showing distribution and average velocity
struct PitchArsenalView: View {
let allPlays: [LiveFeedPlay]
let pitcherName: String
private var pitchSummary: [PitchTypeSummary] {
var grouped: [String: (count: Int, totalSpeed: Double, speedCount: Int)] = [:]
for play in allPlays {
guard let events = play.playEvents else { continue }
for event in events where event.isPitch == true {
let type = event.pitchTypeDescription
var entry = grouped[type, default: (0, 0, 0)]
entry.count += 1
if let speed = event.speedMPH {
entry.totalSpeed += speed
entry.speedCount += 1
}
grouped[type] = entry
}
}
let total = grouped.values.reduce(0) { $0 + $1.count }
guard total > 0 else { return [] }
return grouped.map { type, data in
PitchTypeSummary(
name: type,
count: data.count,
percentage: Double(data.count) / Double(total) * 100,
avgSpeed: data.speedCount > 0 ? data.totalSpeed / Double(data.speedCount) : nil
)
}
.sorted { $0.count > $1.count }
}
var body: some View {
let summary = pitchSummary
if !summary.isEmpty {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("PITCH ARSENAL")
.dataLabelStyle()
Spacer()
Text(pitcherName)
.font(DS.Fonts.bodySmall)
.foregroundStyle(DS.Colors.textTertiary)
}
ForEach(summary, id: \.name) { pitch in
pitchRow(pitch, maxCount: summary.first?.count ?? 1)
}
}
}
}
}
@ViewBuilder
private func pitchRow(_ pitch: PitchTypeSummary, maxCount: Int) -> some View {
HStack(spacing: 10) {
Text(pitch.name)
.font(pitchNameFont)
.foregroundStyle(DS.Colors.textSecondary)
.frame(width: nameWidth, alignment: .leading)
.lineLimit(1)
GeometryReader { geo in
let fraction = maxCount > 0 ? CGFloat(pitch.count) / CGFloat(maxCount) : 0
RoundedRectangle(cornerRadius: 3)
.fill(barColor(for: pitch.name))
.frame(width: geo.size.width * fraction)
}
.frame(height: barHeight)
Text("\(Int(pitch.percentage))%")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textPrimary)
.frame(width: pctWidth, alignment: .trailing)
if let speed = pitch.avgSpeed {
Text("\(Int(speed))")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textTertiary)
.frame(width: speedWidth, alignment: .trailing)
Text("mph")
.dataLabelStyle()
}
}
}
private func barColor(for pitchType: String) -> Color {
let lowered = pitchType.lowercased()
if lowered.contains("fastball") || lowered.contains("sinker") || lowered.contains("cutter") {
return DS.Colors.live
}
if lowered.contains("slider") || lowered.contains("sweep") {
return DS.Colors.interactive
}
if lowered.contains("curve") || lowered.contains("knuckle") {
return DS.Colors.media
}
if lowered.contains("change") || lowered.contains("split") {
return DS.Colors.positive
}
return DS.Colors.warning
}
#if os(tvOS)
private var nameWidth: CGFloat { 140 }
private var pctWidth: CGFloat { 44 }
private var speedWidth: CGFloat { 36 }
private var barHeight: CGFloat { 14 }
private var pitchNameFont: Font { DS.Fonts.bodySmall }
#else
private var nameWidth: CGFloat { 100 }
private var pctWidth: CGFloat { 36 }
private var speedWidth: CGFloat { 30 }
private var barHeight: CGFloat { 10 }
private var pitchNameFont: Font { .system(size: 12, weight: .medium) }
#endif
}
private struct PitchTypeSummary {
let name: String
let count: Int
let percentage: Double
let avgSpeed: Double?
}

View File

@@ -0,0 +1,94 @@
import Charts
import SwiftUI
/// Full-game win probability line chart using Swift Charts
struct WinProbabilityChartView: View {
let entries: [WinProbabilityEntry]
let homeCode: String
let awayCode: String
private var homeColor: Color { TeamAssets.color(for: homeCode) }
private var awayColor: Color { TeamAssets.color(for: awayCode) }
var body: some View {
DataPanel(.standard) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("WIN PROBABILITY")
.dataLabelStyle()
Spacer()
if let latest = entries.last {
HStack(spacing: 12) {
probLabel(code: awayCode, value: latest.awayTeamWinProbability)
probLabel(code: homeCode, value: latest.homeTeamWinProbability)
}
}
}
Chart {
ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in
if let wp = entry.homeTeamWinProbability {
LineMark(
x: .value("AB", index),
y: .value("WP", wp)
)
.foregroundStyle(homeColor)
.interpolationMethod(.catmullRom)
AreaMark(
x: .value("AB", index),
yStart: .value("Base", 50),
yEnd: .value("WP", wp)
)
.foregroundStyle(
wp >= 50
? homeColor.opacity(0.15)
: awayColor.opacity(0.15)
)
.interpolationMethod(.catmullRom)
}
}
RuleMark(y: .value("Even", 50))
.foregroundStyle(DS.Colors.textQuaternary)
.lineStyle(StrokeStyle(lineWidth: 0.5, dash: [4, 4]))
}
.chartYScale(domain: 0...100)
.chartYAxis {
AxisMarks(values: [0, 25, 50, 75, 100]) { value in
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.3))
.foregroundStyle(DS.Colors.textQuaternary)
AxisValueLabel {
Text("\(value.as(Int.self) ?? 0)%")
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
}
}
}
.chartXAxis(.hidden)
.frame(height: chartHeight)
}
}
}
@ViewBuilder
private func probLabel(code: String, value: Double?) -> some View {
HStack(spacing: 4) {
RoundedRectangle(cornerRadius: 2)
.fill(TeamAssets.color(for: code))
.frame(width: 3, height: 14)
Text(code)
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textTertiary)
Text(value.map { "\(Int($0))%" } ?? "-")
.font(DS.Fonts.dataValueCompact)
.foregroundStyle(DS.Colors.textPrimary)
}
}
#if os(tvOS)
private var chartHeight: CGFloat { 180 }
#else
private var chartHeight: CGFloat { 140 }
#endif
}

View File

@@ -13,15 +13,18 @@ struct ContentView: View {
var body: some View {
TabView {
Tab("Games", systemImage: "sportscourt.fill") {
Tab("Today", systemImage: "sportscourt.fill") {
DashboardView()
}
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
Tab("Intel", systemImage: "chart.bar.fill") {
LeagueCenterView()
}
Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") {
MultiStreamView()
}
Tab("Feed", systemImage: "newspaper.fill") {
FeedView()
}
Tab("Settings", systemImage: "gearshape.fill") {
SettingsView()
}

View File

@@ -121,6 +121,25 @@ struct DashboardView: View {
}
.padding(.top, 80)
} else {
// Live situation bar compact strip of all live games
if !viewModel.liveGames.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
LiveIndicator()
Text("LIVE NOW")
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.live)
.kerning(1.5)
}
.padding(.horizontal, 4)
LiveSituationBar(games: viewModel.liveGames) { game in
selectedGame = game
}
.padding(.horizontal, -horizontalPadding)
}
}
// Hero featured game
if let featured = viewModel.featuredGame {
FeaturedGameCard(game: featured) {
@@ -427,19 +446,22 @@ struct DashboardView: View {
}
@ViewBuilder
private func statPill(_ value: String, label: String, color: Color = .blue) -> some View {
private func statPill(_ value: String, label: String, color: Color = DS.Colors.interactive) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.title3.weight(.bold).monospacedDigit())
.font(DS.Fonts.dataValue)
.foregroundStyle(color)
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
.dataLabelStyle()
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
.background(DS.Colors.panelFill)
.overlay(
RoundedRectangle(cornerRadius: DS.Radii.compact)
.strokeBorder(DS.Colors.panelStroke, lineWidth: 0.5)
)
.clipShape(RoundedRectangle(cornerRadius: DS.Radii.compact))
}
// MARK: - Featured Channels

View File

@@ -0,0 +1,71 @@
import SwiftUI
struct FeedView: View {
@State private var viewModel = FeedViewModel()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: DS.Spacing.cardGap) {
// Header
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("FEED")
.font(DS.Fonts.caption)
.foregroundStyle(DS.Colors.textQuaternary)
.kerning(3)
Text("Latest Intel")
#if os(tvOS)
.font(DS.Fonts.tvSectionTitle)
#else
.font(DS.Fonts.sectionTitle)
#endif
.foregroundStyle(DS.Colors.textPrimary)
}
Spacer()
if viewModel.isLoading {
ProgressView()
}
}
if viewModel.items.isEmpty && !viewModel.isLoading {
VStack(spacing: 16) {
Image(systemName: "newspaper")
.font(.system(size: 44))
.foregroundStyle(DS.Colors.textQuaternary)
Text("No feed items yet")
.font(DS.Fonts.body)
.foregroundStyle(DS.Colors.textTertiary)
}
.frame(maxWidth: .infinity)
.padding(.top, 80)
} else {
LazyVStack(spacing: DS.Spacing.itemGap) {
ForEach(viewModel.items) { item in
FeedItemView(item: item)
}
}
}
}
.padding(.horizontal, edgeInset)
.padding(.vertical, DS.Spacing.sectionGap)
}
.background(DS.Colors.background)
.task {
await viewModel.loadFeed()
}
.onAppear {
viewModel.startAutoRefresh()
}
.onDisappear {
viewModel.stopAutoRefresh()
}
}
#if os(tvOS)
private var edgeInset: CGFloat { 60 }
#else
private var edgeInset: CGFloat { 20 }
#endif
}

View File

@@ -28,13 +28,24 @@ struct GameCardView: View {
}
.padding(.horizontal, 22)
Spacer(minLength: 14)
// Mini linescore for live/final games
if let linescore = game.linescore, !game.status.isScheduled {
MiniLinescoreView(
linescore: linescore,
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code
)
.padding(.horizontal, 22)
.padding(.top, 10)
}
Spacer(minLength: 10)
footer
.padding(.horizontal, 22)
.padding(.vertical, 16)
.padding(.vertical, 14)
}
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: 320, alignment: .topLeading)
.frame(maxWidth: .infinity, minHeight: 320, alignment: .topLeading)
.background(cardBackground)
.overlay(alignment: .top) {
HStack(spacing: 0) {

View File

@@ -21,7 +21,22 @@ struct GameCenterView: View {
atBatPanel(feed: feed)
}
if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
// Pitch Arsenal for current pitcher
if let pitcherName = feed.currentPitcher?.fullName {
PitchArsenalView(
allPlays: feed.liveData.plays.allPlays,
pitcherName: pitcherName
)
}
// Win Probability Chart (full game timeline)
if !viewModel.winProbabilityHistory.isEmpty {
WinProbabilityChartView(
entries: viewModel.winProbabilityHistory,
homeCode: game.homeTeam.code,
awayCode: game.awayTeam.code
)
} else if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
winProbabilityPanel(home: wpHome, away: wpAway)
}

View File

@@ -36,6 +36,12 @@ struct LeagueCenterView: View {
scheduleSection
standingsSection
// League Leaders
if !viewModel.leagueLeaders.isEmpty {
leadersSection
}
teamsSection
if let selectedTeam = viewModel.selectedTeam {
@@ -214,6 +220,41 @@ struct LeagueCenterView: View {
.frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading)
}
private var leadersSection: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
Text("League Leaders")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Spacer()
if viewModel.isLoadingLeaders {
ProgressView()
}
}
ScrollView(.horizontal) {
LazyHStack(spacing: DS.Spacing.cardGap) {
ForEach(viewModel.leagueLeaders) { category in
LeaderboardView(category: category)
.frame(width: leaderCardWidth)
}
}
.padding(.vertical, 8)
}
.scrollClipDisabled()
}
}
private var leaderCardWidth: CGFloat {
#if os(tvOS)
380
#else
280
#endif
}
private var standingsSection: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Standings")