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:
@@ -4,15 +4,17 @@ private enum mlbIOSSection: String, CaseIterable, Identifiable {
|
||||
case games
|
||||
case league
|
||||
case multiView
|
||||
case feed
|
||||
case settings
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .games: "Games"
|
||||
case .league: "League"
|
||||
case .games: "Today"
|
||||
case .league: "Intel"
|
||||
case .multiView: "Multi-View"
|
||||
case .feed: "Feed"
|
||||
case .settings: "Settings"
|
||||
}
|
||||
}
|
||||
@@ -20,8 +22,9 @@ private enum mlbIOSSection: String, CaseIterable, Identifiable {
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .games: "sportscourt.fill"
|
||||
case .league: "list.bullet.rectangle.portrait.fill"
|
||||
case .league: "chart.bar.fill"
|
||||
case .multiView: "rectangle.split.2x2.fill"
|
||||
case .feed: "newspaper.fill"
|
||||
case .settings: "gearshape.fill"
|
||||
}
|
||||
}
|
||||
@@ -58,15 +61,18 @@ struct mlbIOSRootView: View {
|
||||
|
||||
private var compactTabs: 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(multiViewTitle, systemImage: "rectangle.split.2x2.fill") {
|
||||
MultiStreamView()
|
||||
}
|
||||
Tab("Feed", systemImage: "newspaper.fill") {
|
||||
FeedView()
|
||||
}
|
||||
Tab("Settings", systemImage: "gearshape.fill") {
|
||||
SettingsView()
|
||||
}
|
||||
@@ -99,6 +105,8 @@ struct mlbIOSRootView: View {
|
||||
LeagueCenterView()
|
||||
case .multiView:
|
||||
MultiStreamView()
|
||||
case .feed:
|
||||
FeedView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
|
||||
@@ -73,6 +73,30 @@
|
||||
FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; };
|
||||
FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; };
|
||||
FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; };
|
||||
37407BFDD8DE522452EF59A9 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A41C116893411524EA91B1 /* DesignSystem.swift */; };
|
||||
E1C4B52624855C00D4248CF7 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A41C116893411524EA91B1 /* DesignSystem.swift */; };
|
||||
1709C71D035868F36D786A6C /* DataPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */; };
|
||||
9F57F341DE0236C88F5D9AB4 /* DataPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */; };
|
||||
3A707B3D07159E3794810DA6 /* MiniLinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */; };
|
||||
394BFB3C349C866C45652736 /* MiniLinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */; };
|
||||
E1EE34169F3361C53FB042C2 /* DiamondView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92817781B4EB8AC773F94A1B /* DiamondView.swift */; };
|
||||
C9BAFB42FB62DB304C446C5F /* DiamondView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92817781B4EB8AC773F94A1B /* DiamondView.swift */; };
|
||||
C3245C14690AF4CE8882130E /* LiveSituationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */; };
|
||||
492CF47C161A3F31DED29740 /* LiveSituationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */; };
|
||||
312CC5DD0539384FDF65BEB7 /* WinProbabilityChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */; };
|
||||
4DDB697A8363736A0E0C345E /* WinProbabilityChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */; };
|
||||
F17BF3697CF0008A9417FE08 /* PitchArsenalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */; };
|
||||
026E8F398909A35C0DD41D16 /* PitchArsenalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */; };
|
||||
CB3E29DA692C18137C037F93 /* MLBWebDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */; };
|
||||
E1DA8514E6177069C04B0416 /* MLBWebDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */; };
|
||||
C7C0D9F7B6A3EBD4D9F4B973 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C920FA380D9DDDED113068E3 /* FeedViewModel.swift */; };
|
||||
560051D308A26379374EC9C4 /* FeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C920FA380D9DDDED113068E3 /* FeedViewModel.swift */; };
|
||||
5802E2BE747B7B59F00AE1E7 /* FeedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F68D38B739C81D7747CC412 /* FeedItemView.swift */; };
|
||||
9BF7942D0252B0EB87DE58B3 /* FeedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F68D38B739C81D7747CC412 /* FeedItemView.swift */; };
|
||||
C5057ECFA9FCBFFC6A8A7E15 /* FeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C95E73FFDAFE2E26B06C2 /* FeedView.swift */; };
|
||||
6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C95E73FFDAFE2E26B06C2 /* FeedView.swift */; };
|
||||
DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */; };
|
||||
94D2242FE48B5FECE15116FF /* LeaderboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -129,6 +153,18 @@
|
||||
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = "<group>"; };
|
||||
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = "<group>"; };
|
||||
FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = "<group>"; };
|
||||
60A41C116893411524EA91B1 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = "<group>"; };
|
||||
C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPanel.swift; sourceTree = "<group>"; };
|
||||
1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniLinescoreView.swift; sourceTree = "<group>"; };
|
||||
92817781B4EB8AC773F94A1B /* DiamondView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiamondView.swift; sourceTree = "<group>"; };
|
||||
0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveSituationBar.swift; sourceTree = "<group>"; };
|
||||
26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WinProbabilityChartView.swift; sourceTree = "<group>"; };
|
||||
A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitchArsenalView.swift; sourceTree = "<group>"; };
|
||||
7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBWebDataService.swift; sourceTree = "<group>"; };
|
||||
C920FA380D9DDDED113068E3 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = "<group>"; };
|
||||
9F68D38B739C81D7747CC412 /* FeedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemView.swift; sourceTree = "<group>"; };
|
||||
981C95E73FFDAFE2E26B06C2 /* FeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedView.swift; sourceTree = "<group>"; };
|
||||
726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -137,6 +173,7 @@
|
||||
children = (
|
||||
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */,
|
||||
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */,
|
||||
C920FA380D9DDDED113068E3 /* FeedViewModel.swift */,
|
||||
7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
@@ -170,6 +207,15 @@
|
||||
children = (
|
||||
C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */,
|
||||
327A33F2676712C2B5B4AF2F /* LinescoreView.swift */,
|
||||
726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */,
|
||||
9F68D38B739C81D7747CC412 /* FeedItemView.swift */,
|
||||
A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */,
|
||||
26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */,
|
||||
0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */,
|
||||
92817781B4EB8AC773F94A1B /* DiamondView.swift */,
|
||||
1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */,
|
||||
C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */,
|
||||
60A41C116893411524EA91B1 /* DesignSystem.swift */,
|
||||
81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */,
|
||||
C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */,
|
||||
7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */,
|
||||
@@ -227,6 +273,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */,
|
||||
7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */,
|
||||
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */,
|
||||
C3A003EA560655E4B2200DBE /* VideoShuffle.swift */,
|
||||
);
|
||||
@@ -238,6 +285,7 @@
|
||||
children = (
|
||||
3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */,
|
||||
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */,
|
||||
981C95E73FFDAFE2E26B06C2 /* FeedView.swift */,
|
||||
102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */,
|
||||
E21E3115B8A9B4C798ECE828 /* GameCardView.swift */,
|
||||
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */,
|
||||
@@ -392,6 +440,18 @@
|
||||
8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */,
|
||||
E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */,
|
||||
4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.swift in Sources */,
|
||||
94D2242FE48B5FECE15116FF /* LeaderboardView.swift in Sources */,
|
||||
6581BE8213BB231538C9EB8E /* FeedView.swift in Sources */,
|
||||
9BF7942D0252B0EB87DE58B3 /* FeedItemView.swift in Sources */,
|
||||
560051D308A26379374EC9C4 /* FeedViewModel.swift in Sources */,
|
||||
E1DA8514E6177069C04B0416 /* MLBWebDataService.swift in Sources */,
|
||||
026E8F398909A35C0DD41D16 /* PitchArsenalView.swift in Sources */,
|
||||
4DDB697A8363736A0E0C345E /* WinProbabilityChartView.swift in Sources */,
|
||||
492CF47C161A3F31DED29740 /* LiveSituationBar.swift in Sources */,
|
||||
C9BAFB42FB62DB304C446C5F /* DiamondView.swift in Sources */,
|
||||
394BFB3C349C866C45652736 /* MiniLinescoreView.swift in Sources */,
|
||||
9F57F341DE0236C88F5D9AB4 /* DataPanel.swift in Sources */,
|
||||
E1C4B52624855C00D4248CF7 /* DesignSystem.swift in Sources */,
|
||||
0653C2F5CC55F4234E8950E5 /* LiveIndicator.swift in Sources */,
|
||||
D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */,
|
||||
8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */,
|
||||
@@ -439,6 +499,18 @@
|
||||
07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */,
|
||||
B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */,
|
||||
051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */,
|
||||
DB34919E15ABAA1B40DF9A5B /* LeaderboardView.swift in Sources */,
|
||||
C5057ECFA9FCBFFC6A8A7E15 /* FeedView.swift in Sources */,
|
||||
5802E2BE747B7B59F00AE1E7 /* FeedItemView.swift in Sources */,
|
||||
C7C0D9F7B6A3EBD4D9F4B973 /* FeedViewModel.swift in Sources */,
|
||||
CB3E29DA692C18137C037F93 /* MLBWebDataService.swift in Sources */,
|
||||
F17BF3697CF0008A9417FE08 /* PitchArsenalView.swift in Sources */,
|
||||
312CC5DD0539384FDF65BEB7 /* WinProbabilityChartView.swift in Sources */,
|
||||
C3245C14690AF4CE8882130E /* LiveSituationBar.swift in Sources */,
|
||||
E1EE34169F3361C53FB042C2 /* DiamondView.swift in Sources */,
|
||||
3A707B3D07159E3794810DA6 /* MiniLinescoreView.swift in Sources */,
|
||||
1709C71D035868F36D786A6C /* DataPanel.swift in Sources */,
|
||||
37407BFDD8DE522452EF59A9 /* DesignSystem.swift in Sources */,
|
||||
E7FB366456557069990F550C /* LiveIndicator.swift in Sources */,
|
||||
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */,
|
||||
1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */,
|
||||
|
||||
236
mlbTVOS/Services/MLBWebDataService.swift
Normal file
236
mlbTVOS/Services/MLBWebDataService.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private let webDataLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "WebData")
|
||||
|
||||
private func logWebData(_ message: String) {
|
||||
webDataLogger.debug("\(message, privacy: .public)")
|
||||
print("[WebData] \(message)")
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct NewsHeadline: Identifiable, Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let summary: String
|
||||
let timestamp: Date
|
||||
let imageURL: URL?
|
||||
}
|
||||
|
||||
struct InjuryReport: Identifiable, Sendable {
|
||||
let id: String
|
||||
let teamCode: String
|
||||
let playerName: String
|
||||
let position: String
|
||||
let status: String // "10-Day IL", "60-Day IL", "Day-to-Day"
|
||||
let date: Date
|
||||
let notes: String
|
||||
}
|
||||
|
||||
struct Transaction: Identifiable, Sendable {
|
||||
let id: String
|
||||
let teamCode: String
|
||||
let description: String
|
||||
let date: Date
|
||||
let type: String // "Trade", "DFA", "Call-Up", "Placed on IL"
|
||||
}
|
||||
|
||||
struct LeagueLeader: Identifiable, Sendable {
|
||||
let id: String
|
||||
let rank: Int
|
||||
let playerName: String
|
||||
let teamCode: String
|
||||
let value: String
|
||||
let personId: Int?
|
||||
}
|
||||
|
||||
struct LeaderCategory: Identifiable, Sendable {
|
||||
let id: String
|
||||
let name: String
|
||||
let leaders: [LeagueLeader]
|
||||
}
|
||||
|
||||
// MARK: - Service
|
||||
|
||||
actor MLBWebDataService {
|
||||
private let statsAPI = MLBStatsAPI()
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 15
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
// MARK: - League Leaders (via Stats API)
|
||||
|
||||
func fetchLeagueLeaders() async throws -> [LeaderCategory] {
|
||||
let categories = [
|
||||
("homeRuns", "Home Runs"),
|
||||
("battingAverage", "Batting Average"),
|
||||
("runsBattedIn", "RBI"),
|
||||
("earnedRunAverage", "ERA"),
|
||||
("strikeouts", "Strikeouts"),
|
||||
("wins", "Wins"),
|
||||
("saves", "Saves"),
|
||||
("stolenBases", "Stolen Bases"),
|
||||
("onBasePlusSlugging", "OPS"),
|
||||
("walksHitsPerInningPitched", "WHIP"),
|
||||
]
|
||||
|
||||
var results: [LeaderCategory] = []
|
||||
|
||||
for (code, name) in categories {
|
||||
let url = URL(string: "https://statsapi.mlb.com/api/v1/stats/leaders?leaderCategories=\(code)&season=\(currentSeason)&limit=5&sportId=1")!
|
||||
logWebData("fetchLeagueLeaders category=\(code)")
|
||||
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
let response = try JSONDecoder().decode(LeaderResponse.self, from: data)
|
||||
|
||||
if let category = response.leagueLeaders?.first {
|
||||
let leaders = (category.leaders ?? []).prefix(5).enumerated().map { index, leader in
|
||||
LeagueLeader(
|
||||
id: "\(code)-\(index)",
|
||||
rank: leader.rank ?? (index + 1),
|
||||
playerName: leader.person?.fullName ?? "Unknown",
|
||||
teamCode: leader.team?.abbreviation ?? "",
|
||||
value: leader.value ?? "",
|
||||
personId: leader.person?.id
|
||||
)
|
||||
}
|
||||
results.append(LeaderCategory(id: code, name: name, leaders: Array(leaders)))
|
||||
}
|
||||
} catch {
|
||||
logWebData("fetchLeagueLeaders FAILED category=\(code) error=\(error)")
|
||||
}
|
||||
}
|
||||
|
||||
logWebData("fetchLeagueLeaders complete categories=\(results.count)")
|
||||
return results
|
||||
}
|
||||
|
||||
// MARK: - News Headlines (from MLB.com)
|
||||
|
||||
func fetchNewsHeadlines() async -> [NewsHeadline] {
|
||||
let url = URL(string: "https://statsapi.mlb.com/api/v1/news?sportId=1")!
|
||||
logWebData("fetchNewsHeadlines")
|
||||
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
let response = try JSONDecoder().decode(NewsResponse.self, from: data)
|
||||
|
||||
let headlines = (response.articles ?? []).prefix(15).map { article in
|
||||
NewsHeadline(
|
||||
id: article.slug ?? UUID().uuidString,
|
||||
title: article.headline ?? "Untitled",
|
||||
summary: article.subhead ?? "",
|
||||
timestamp: ISO8601DateFormatter().date(from: article.date ?? "") ?? Date(),
|
||||
imageURL: article.image?.cuts?.first?.src.flatMap(URL.init)
|
||||
)
|
||||
}
|
||||
logWebData("fetchNewsHeadlines success count=\(headlines.count)")
|
||||
return Array(headlines)
|
||||
} catch {
|
||||
logWebData("fetchNewsHeadlines FAILED error=\(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transactions (from Stats API)
|
||||
|
||||
func fetchTransactions() async -> [Transaction] {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MM/dd/yyyy"
|
||||
let today = formatter.string(from: Date())
|
||||
let weekAgo = formatter.string(from: Calendar.current.date(byAdding: .day, value: -7, to: Date())!)
|
||||
|
||||
let url = URL(string: "https://statsapi.mlb.com/api/v1/transactions?startDate=\(weekAgo)&endDate=\(today)&sportId=1")!
|
||||
logWebData("fetchTransactions from=\(weekAgo) to=\(today)")
|
||||
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
let response = try JSONDecoder().decode(TransactionResponse.self, from: data)
|
||||
|
||||
let transactions = (response.transactions ?? []).prefix(30).enumerated().map { index, tx in
|
||||
Transaction(
|
||||
id: "\(tx.id ?? index)",
|
||||
teamCode: tx.team?.abbreviation ?? "",
|
||||
description: tx.description ?? "",
|
||||
date: ISO8601DateFormatter().date(from: tx.date ?? "") ?? Date(),
|
||||
type: tx.typeDesc ?? "Transaction"
|
||||
)
|
||||
}
|
||||
logWebData("fetchTransactions success count=\(transactions.count)")
|
||||
return Array(transactions)
|
||||
} catch {
|
||||
logWebData("fetchTransactions FAILED error=\(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private var currentSeason: Int {
|
||||
Calendar.current.component(.year, from: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Response Models
|
||||
|
||||
private struct LeaderResponse: Codable {
|
||||
let leagueLeaders: [LeaderCategoryResponse]?
|
||||
}
|
||||
|
||||
private struct LeaderCategoryResponse: Codable {
|
||||
let leaderCategory: String?
|
||||
let leaders: [LeaderEntry]?
|
||||
}
|
||||
|
||||
private struct LeaderEntry: Codable {
|
||||
let rank: Int?
|
||||
let value: String?
|
||||
let person: PersonRef?
|
||||
let team: TeamRef?
|
||||
}
|
||||
|
||||
private struct PersonRef: Codable {
|
||||
let id: Int?
|
||||
let fullName: String?
|
||||
}
|
||||
|
||||
private struct TeamRef: Codable {
|
||||
let id: Int?
|
||||
let abbreviation: String?
|
||||
}
|
||||
|
||||
private struct NewsResponse: Codable {
|
||||
let articles: [NewsArticle]?
|
||||
}
|
||||
|
||||
private struct NewsArticle: Codable {
|
||||
let slug: String?
|
||||
let headline: String?
|
||||
let subhead: String?
|
||||
let date: String?
|
||||
let image: NewsImage?
|
||||
}
|
||||
|
||||
private struct NewsImage: Codable {
|
||||
let cuts: [NewsImageCut]?
|
||||
}
|
||||
|
||||
private struct NewsImageCut: Codable {
|
||||
let src: String?
|
||||
}
|
||||
|
||||
private struct TransactionResponse: Codable {
|
||||
let transactions: [TransactionEntry]?
|
||||
}
|
||||
|
||||
private struct TransactionEntry: Codable {
|
||||
let id: Int?
|
||||
let description: String?
|
||||
let date: String?
|
||||
let typeDesc: String?
|
||||
let team: TeamRef?
|
||||
}
|
||||
95
mlbTVOS/ViewModels/FeedViewModel.swift
Normal file
95
mlbTVOS/ViewModels/FeedViewModel.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
private let feedLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Feed")
|
||||
|
||||
private func logFeed(_ message: String) {
|
||||
feedLogger.debug("\(message, privacy: .public)")
|
||||
print("[Feed] \(message)")
|
||||
}
|
||||
|
||||
enum FeedItemType: Sendable {
|
||||
case news
|
||||
case transaction
|
||||
case scoring
|
||||
}
|
||||
|
||||
struct FeedItem: Identifiable, Sendable {
|
||||
let id: String
|
||||
let type: FeedItemType
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let teamCode: String?
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class FeedViewModel {
|
||||
var items: [FeedItem] = []
|
||||
var isLoading = false
|
||||
|
||||
@ObservationIgnored
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
private let webService = MLBWebDataService()
|
||||
|
||||
func loadFeed() async {
|
||||
isLoading = true
|
||||
logFeed("loadFeed start")
|
||||
|
||||
async let newsTask = webService.fetchNewsHeadlines()
|
||||
async let transactionsTask = webService.fetchTransactions()
|
||||
|
||||
let news = await newsTask
|
||||
let transactions = await transactionsTask
|
||||
|
||||
var allItems: [FeedItem] = []
|
||||
|
||||
// News
|
||||
for headline in news {
|
||||
allItems.append(FeedItem(
|
||||
id: "news-\(headline.id)",
|
||||
type: .news,
|
||||
title: headline.title,
|
||||
subtitle: headline.summary,
|
||||
teamCode: nil,
|
||||
timestamp: headline.timestamp
|
||||
))
|
||||
}
|
||||
|
||||
// Transactions
|
||||
for tx in transactions {
|
||||
allItems.append(FeedItem(
|
||||
id: "tx-\(tx.id)",
|
||||
type: .transaction,
|
||||
title: tx.description,
|
||||
subtitle: tx.type,
|
||||
teamCode: tx.teamCode.isEmpty ? nil : tx.teamCode,
|
||||
timestamp: tx.date
|
||||
))
|
||||
}
|
||||
|
||||
// Sort reverse chronological
|
||||
items = allItems.sorted { $0.timestamp > $1.timestamp }
|
||||
isLoading = false
|
||||
logFeed("loadFeed complete items=\(items.count)")
|
||||
}
|
||||
|
||||
func startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
refreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(300))
|
||||
guard !Task.isCancelled, let self else { break }
|
||||
await self.loadFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopAutoRefresh() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ final class GameCenterViewModel {
|
||||
var highlights: [Highlight] = []
|
||||
var winProbabilityHome: Double?
|
||||
var winProbabilityAway: Double?
|
||||
var winProbabilityHistory: [WinProbabilityEntry] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var lastUpdated: Date?
|
||||
@@ -81,6 +82,7 @@ final class GameCenterViewModel {
|
||||
private func refreshWinProbability(gamePk: String) async {
|
||||
do {
|
||||
let entries = try await statsAPI.fetchWinProbability(gamePk: gamePk)
|
||||
winProbabilityHistory = entries
|
||||
if let latest = entries.last,
|
||||
let home = latest.homeTeamWinProbability,
|
||||
let away = latest.awayTeamWinProbability {
|
||||
|
||||
@@ -7,6 +7,8 @@ final class LeagueCenterViewModel {
|
||||
var scheduleGames: [StatsGame] = []
|
||||
var standings: [StandingsDivisionRecord] = []
|
||||
var teams: [LeagueTeamSummary] = []
|
||||
var leagueLeaders: [LeaderCategory] = []
|
||||
var isLoadingLeaders = false
|
||||
|
||||
var selectedTeam: TeamProfile?
|
||||
var roster: [RosterPlayerSummary] = []
|
||||
@@ -21,6 +23,7 @@ final class LeagueCenterViewModel {
|
||||
var playerErrorMessage: String?
|
||||
|
||||
private let statsAPI = MLBStatsAPI()
|
||||
private let webService = MLBWebDataService()
|
||||
private(set) var scheduleDate = Date()
|
||||
|
||||
private var seasonString: String {
|
||||
@@ -56,6 +59,19 @@ final class LeagueCenterViewModel {
|
||||
}
|
||||
|
||||
isLoadingOverview = false
|
||||
|
||||
// Load leaders in the background
|
||||
Task { await loadLeaders() }
|
||||
}
|
||||
|
||||
func loadLeaders() async {
|
||||
isLoadingLeaders = true
|
||||
do {
|
||||
leagueLeaders = try await webService.fetchLeagueLeaders()
|
||||
} catch {
|
||||
// Leaders are supplementary
|
||||
}
|
||||
isLoadingLeaders = false
|
||||
}
|
||||
|
||||
func goToPreviousDay() async {
|
||||
|
||||
69
mlbTVOS/Views/Components/DataPanel.swift
Normal file
69
mlbTVOS/Views/Components/DataPanel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
101
mlbTVOS/Views/Components/DesignSystem.swift
Normal file
101
mlbTVOS/Views/Components/DesignSystem.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
120
mlbTVOS/Views/Components/DiamondView.swift
Normal file
120
mlbTVOS/Views/Components/DiamondView.swift
Normal 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
|
||||
}
|
||||
112
mlbTVOS/Views/Components/FeedItemView.swift
Normal file
112
mlbTVOS/Views/Components/FeedItemView.swift
Normal 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
|
||||
}
|
||||
80
mlbTVOS/Views/Components/LeaderboardView.swift
Normal file
80
mlbTVOS/Views/Components/LeaderboardView.swift
Normal 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
|
||||
}
|
||||
101
mlbTVOS/Views/Components/LiveSituationBar.swift
Normal file
101
mlbTVOS/Views/Components/LiveSituationBar.swift
Normal 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
|
||||
}
|
||||
}
|
||||
52
mlbTVOS/Views/Components/MiniLinescoreView.swift
Normal file
52
mlbTVOS/Views/Components/MiniLinescoreView.swift
Normal 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
|
||||
}
|
||||
131
mlbTVOS/Views/Components/PitchArsenalView.swift
Normal file
131
mlbTVOS/Views/Components/PitchArsenalView.swift
Normal 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?
|
||||
}
|
||||
94
mlbTVOS/Views/Components/WinProbabilityChartView.swift
Normal file
94
mlbTVOS/Views/Components/WinProbabilityChartView.swift
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
71
mlbTVOS/Views/FeedView.swift
Normal file
71
mlbTVOS/Views/FeedView.swift
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user