diff --git a/mlbIOS/Views/mlbIOSRootView.swift b/mlbIOS/Views/mlbIOSRootView.swift index b9027d9..326488c 100644 --- a/mlbIOS/Views/mlbIOSRootView.swift +++ b/mlbIOS/Views/mlbIOSRootView.swift @@ -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() } diff --git a/mlbTVOS.xcodeproj/project.pbxproj b/mlbTVOS.xcodeproj/project.pbxproj index 6177703..b2cc552 100644 --- a/mlbTVOS.xcodeproj/project.pbxproj +++ b/mlbTVOS.xcodeproj/project.pbxproj @@ -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 = ""; }; F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = ""; }; FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = ""; }; + 60A41C116893411524EA91B1 /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = ""; }; + C2BCF01456B595D46DBEDFD4 /* DataPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPanel.swift; sourceTree = ""; }; + 1D48B64BA00C6B2DF270EF92 /* MiniLinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniLinescoreView.swift; sourceTree = ""; }; + 92817781B4EB8AC773F94A1B /* DiamondView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiamondView.swift; sourceTree = ""; }; + 0502ADB4033DE026238AAE01 /* LiveSituationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveSituationBar.swift; sourceTree = ""; }; + 26F2E60D9F3315064FC7B793 /* WinProbabilityChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WinProbabilityChartView.swift; sourceTree = ""; }; + A86C820F4ABDD390A1E3F1FA /* PitchArsenalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitchArsenalView.swift; sourceTree = ""; }; + 7EB8E3E205222A55700CF24C /* MLBWebDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBWebDataService.swift; sourceTree = ""; }; + C920FA380D9DDDED113068E3 /* FeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedViewModel.swift; sourceTree = ""; }; + 9F68D38B739C81D7747CC412 /* FeedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemView.swift; sourceTree = ""; }; + 981C95E73FFDAFE2E26B06C2 /* FeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedView.swift; sourceTree = ""; }; + 726AEA37AC2C75C017A888C1 /* LeaderboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeaderboardView.swift; sourceTree = ""; }; /* 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 */, diff --git a/mlbTVOS/Services/MLBWebDataService.swift b/mlbTVOS/Services/MLBWebDataService.swift new file mode 100644 index 0000000..2664708 --- /dev/null +++ b/mlbTVOS/Services/MLBWebDataService.swift @@ -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? +} diff --git a/mlbTVOS/ViewModels/FeedViewModel.swift b/mlbTVOS/ViewModels/FeedViewModel.swift new file mode 100644 index 0000000..880fefb --- /dev/null +++ b/mlbTVOS/ViewModels/FeedViewModel.swift @@ -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? + + 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 + } +} diff --git a/mlbTVOS/ViewModels/GameCenterViewModel.swift b/mlbTVOS/ViewModels/GameCenterViewModel.swift index 70bc61d..798f86b 100644 --- a/mlbTVOS/ViewModels/GameCenterViewModel.swift +++ b/mlbTVOS/ViewModels/GameCenterViewModel.swift @@ -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 { diff --git a/mlbTVOS/ViewModels/LeagueCenterViewModel.swift b/mlbTVOS/ViewModels/LeagueCenterViewModel.swift index fb7dd1e..f852c3a 100644 --- a/mlbTVOS/ViewModels/LeagueCenterViewModel.swift +++ b/mlbTVOS/ViewModels/LeagueCenterViewModel.swift @@ -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 { diff --git a/mlbTVOS/Views/Components/DataPanel.swift b/mlbTVOS/Views/Components/DataPanel.swift new file mode 100644 index 0000000..9a754ff --- /dev/null +++ b/mlbTVOS/Views/Components/DataPanel.swift @@ -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: 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 + } +} diff --git a/mlbTVOS/Views/Components/DesignSystem.swift b/mlbTVOS/Views/Components/DesignSystem.swift new file mode 100644 index 0000000..76b6f19 --- /dev/null +++ b/mlbTVOS/Views/Components/DesignSystem.swift @@ -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()) + } +} diff --git a/mlbTVOS/Views/Components/DiamondView.swift b/mlbTVOS/Views/Components/DiamondView.swift new file mode 100644 index 0000000..7c5227f --- /dev/null +++ b/mlbTVOS/Views/Components/DiamondView.swift @@ -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 +} diff --git a/mlbTVOS/Views/Components/FeedItemView.swift b/mlbTVOS/Views/Components/FeedItemView.swift new file mode 100644 index 0000000..07ef375 --- /dev/null +++ b/mlbTVOS/Views/Components/FeedItemView.swift @@ -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 +} diff --git a/mlbTVOS/Views/Components/LeaderboardView.swift b/mlbTVOS/Views/Components/LeaderboardView.swift new file mode 100644 index 0000000..b374b47 --- /dev/null +++ b/mlbTVOS/Views/Components/LeaderboardView.swift @@ -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 +} diff --git a/mlbTVOS/Views/Components/LiveSituationBar.swift b/mlbTVOS/Views/Components/LiveSituationBar.swift new file mode 100644 index 0000000..dc7141c --- /dev/null +++ b/mlbTVOS/Views/Components/LiveSituationBar.swift @@ -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 + } +} diff --git a/mlbTVOS/Views/Components/MiniLinescoreView.swift b/mlbTVOS/Views/Components/MiniLinescoreView.swift new file mode 100644 index 0000000..adf2918 --- /dev/null +++ b/mlbTVOS/Views/Components/MiniLinescoreView.swift @@ -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 +} diff --git a/mlbTVOS/Views/Components/PitchArsenalView.swift b/mlbTVOS/Views/Components/PitchArsenalView.swift new file mode 100644 index 0000000..cc25474 --- /dev/null +++ b/mlbTVOS/Views/Components/PitchArsenalView.swift @@ -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? +} diff --git a/mlbTVOS/Views/Components/WinProbabilityChartView.swift b/mlbTVOS/Views/Components/WinProbabilityChartView.swift new file mode 100644 index 0000000..859c039 --- /dev/null +++ b/mlbTVOS/Views/Components/WinProbabilityChartView.swift @@ -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 +} diff --git a/mlbTVOS/Views/ContentView.swift b/mlbTVOS/Views/ContentView.swift index bd3f10f..f6f223d 100644 --- a/mlbTVOS/Views/ContentView.swift +++ b/mlbTVOS/Views/ContentView.swift @@ -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() } diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index 0b8b1b8..eb9f2d7 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -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 diff --git a/mlbTVOS/Views/FeedView.swift b/mlbTVOS/Views/FeedView.swift new file mode 100644 index 0000000..9f64c05 --- /dev/null +++ b/mlbTVOS/Views/FeedView.swift @@ -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 +} diff --git a/mlbTVOS/Views/GameCardView.swift b/mlbTVOS/Views/GameCardView.swift index 5d062a8..1d115d2 100644 --- a/mlbTVOS/Views/GameCardView.swift +++ b/mlbTVOS/Views/GameCardView.swift @@ -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) { diff --git a/mlbTVOS/Views/GameCenterView.swift b/mlbTVOS/Views/GameCenterView.swift index 02e6e38..c26f931 100644 --- a/mlbTVOS/Views/GameCenterView.swift +++ b/mlbTVOS/Views/GameCenterView.swift @@ -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) } diff --git a/mlbTVOS/Views/LeagueCenterView.swift b/mlbTVOS/Views/LeagueCenterView.swift index 7ca41f9..de91d85 100644 --- a/mlbTVOS/Views/LeagueCenterView.swift +++ b/mlbTVOS/Views/LeagueCenterView.swift @@ -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")