Initial commit
This commit is contained in:
340
mlbTVOS/Views/DashboardView.swift
Normal file
340
mlbTVOS/Views/DashboardView.swift
Normal file
@@ -0,0 +1,340 @@
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
|
||||
private let dashboardLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Dashboard")
|
||||
|
||||
private func logDashboard(_ message: String) {
|
||||
dashboardLogger.debug("\(message, privacy: .public)")
|
||||
print("[Dashboard] \(message)")
|
||||
}
|
||||
|
||||
struct DashboardView: View {
|
||||
@Environment(GamesViewModel.self) private var viewModel
|
||||
@State private var selectedGame: Game?
|
||||
@State private var fullScreenBroadcast: BroadcastSelection?
|
||||
@State private var pendingFullScreenBroadcast: BroadcastSelection?
|
||||
@State private var showMLBNetworkSheet = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 50) {
|
||||
headerSection
|
||||
|
||||
if viewModel.isLoading {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView("Loading games...")
|
||||
.font(.title3)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 80)
|
||||
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(error)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") {
|
||||
Task { await viewModel.loadGames() }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 80)
|
||||
} else {
|
||||
// Hero featured game
|
||||
if let featured = viewModel.featuredGame {
|
||||
FeaturedGameCard(game: featured) {
|
||||
selectedGame = featured
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id)
|
||||
}
|
||||
if !viewModel.scheduledGames.isEmpty {
|
||||
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
|
||||
}
|
||||
if !viewModel.finalGames.isEmpty {
|
||||
gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id)
|
||||
}
|
||||
}
|
||||
|
||||
mlbNetworkCard
|
||||
|
||||
if !viewModel.activeStreams.isEmpty {
|
||||
multiViewStatus
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 60)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
.onAppear {
|
||||
logDashboard("DashboardView appeared")
|
||||
viewModel.startAutoRefresh()
|
||||
}
|
||||
.onDisappear {
|
||||
logDashboard("DashboardView disappeared")
|
||||
viewModel.stopAutoRefresh()
|
||||
}
|
||||
.sheet(item: $selectedGame, onDismiss: presentPendingFullScreenBroadcast) { game in
|
||||
StreamOptionsSheet(game: game) { broadcast in
|
||||
logDashboard("Queued fullscreen broadcast from game sheet broadcastId=\(broadcast.broadcast.id) gameId=\(broadcast.game.id)")
|
||||
pendingFullScreenBroadcast = broadcast
|
||||
selectedGame = nil
|
||||
}
|
||||
}
|
||||
.fullScreenCover(item: $fullScreenBroadcast) { selection in
|
||||
SingleStreamPlaybackScreen(
|
||||
resolveURL: {
|
||||
logDashboard("resolveURL closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||
if selection.broadcast.id == "MLBN" {
|
||||
return await viewModel.buildEventStreamURL(event: "MLBN")
|
||||
}
|
||||
let s = ActiveStream(
|
||||
id: selection.broadcast.id,
|
||||
game: selection.game,
|
||||
label: selection.broadcast.displayLabel,
|
||||
mediaId: selection.broadcast.mediaId,
|
||||
streamURLString: selection.broadcast.streamURL
|
||||
)
|
||||
return await viewModel.resolveStreamURL(for: s)
|
||||
},
|
||||
tickerGames: viewModel.games
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||
}
|
||||
}
|
||||
.onChange(of: fullScreenBroadcast?.id) { _, newValue in
|
||||
logDashboard("fullScreenBroadcast changed newValue=\(newValue ?? "nil")")
|
||||
}
|
||||
.sheet(isPresented: $showMLBNetworkSheet, onDismiss: presentPendingFullScreenBroadcast) {
|
||||
MLBNetworkSheet(
|
||||
onWatchFullScreen: {
|
||||
logDashboard("Queued fullscreen broadcast from MLB Network sheet")
|
||||
showMLBNetworkSheet = false
|
||||
pendingFullScreenBroadcast = mlbNetworkBroadcastSelection
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentPendingFullScreenBroadcast() {
|
||||
guard selectedGame == nil, !showMLBNetworkSheet else {
|
||||
logDashboard("Skipped pending fullscreen presentation because another sheet is still active")
|
||||
return
|
||||
}
|
||||
|
||||
guard let pendingFullScreenBroadcast else { return }
|
||||
logDashboard(
|
||||
"Presenting pending fullscreen broadcast broadcastId=\(pendingFullScreenBroadcast.broadcast.id) gameId=\(pendingFullScreenBroadcast.game.id)"
|
||||
)
|
||||
self.pendingFullScreenBroadcast = nil
|
||||
DispatchQueue.main.async {
|
||||
fullScreenBroadcast = pendingFullScreenBroadcast
|
||||
}
|
||||
}
|
||||
|
||||
private var mlbNetworkBroadcastSelection: BroadcastSelection {
|
||||
let bc = Broadcast(
|
||||
id: "MLBN",
|
||||
teamCode: "MLBN",
|
||||
name: "MLB Network",
|
||||
mediaId: "",
|
||||
streamURL: ""
|
||||
)
|
||||
let game = Game(
|
||||
id: "MLBN",
|
||||
awayTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
|
||||
homeTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
|
||||
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
|
||||
pitchers: nil, gamePk: nil, gameDate: "",
|
||||
broadcasts: [], isBlackedOut: false
|
||||
)
|
||||
return BroadcastSelection(broadcast: bc, game: game)
|
||||
}
|
||||
|
||||
// MARK: - Game Shelf (Horizontal)
|
||||
|
||||
@ViewBuilder
|
||||
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View {
|
||||
let filtered = games.filter { $0.id != excludeId }
|
||||
if !filtered.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Label(title, systemImage: icon)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 30) {
|
||||
ForEach(filtered) { game in
|
||||
GameCardView(game: game) {
|
||||
selectedGame = game
|
||||
}
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
@ViewBuilder
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 24) {
|
||||
HStack(alignment: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("MLB")
|
||||
.font(.headline.weight(.black))
|
||||
.foregroundStyle(.secondary)
|
||||
.kerning(4)
|
||||
Text(viewModel.displayDateString)
|
||||
.font(.system(size: 40, weight: .bold))
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 16) {
|
||||
statPill("\(viewModel.games.count)", label: "Games")
|
||||
if !viewModel.liveGames.isEmpty {
|
||||
statPill("\(viewModel.liveGames.count)", label: "Live", color: .red)
|
||||
}
|
||||
if !viewModel.activeStreams.isEmpty {
|
||||
statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
Button {
|
||||
Task { await viewModel.goToPreviousDay() }
|
||||
} label: {
|
||||
Label("Previous Day", systemImage: "chevron.left")
|
||||
}
|
||||
|
||||
if !viewModel.isToday {
|
||||
Button {
|
||||
Task { await viewModel.goToToday() }
|
||||
} label: {
|
||||
Label("Today", systemImage: "calendar")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await viewModel.goToNextDay() }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text("Next Day")
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statPill(_ value: String, label: String, color: Color = .blue) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(value)
|
||||
.font(.title3.weight(.bold).monospacedDigit())
|
||||
.foregroundStyle(color)
|
||||
Text(label)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
// MARK: - MLB Network
|
||||
|
||||
@ViewBuilder
|
||||
private var mlbNetworkCard: some View {
|
||||
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
|
||||
Button {
|
||||
showMLBNetworkSheet = true
|
||||
} label: {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "tv.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(.blue.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("MLB Network")
|
||||
.font(.title3.weight(.bold))
|
||||
Text("Live coverage, analysis & highlights")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if added {
|
||||
Label("In Multi-View", systemImage: "checkmark.circle.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
}
|
||||
|
||||
// MARK: - Multi-View Status
|
||||
|
||||
@ViewBuilder
|
||||
private var multiViewStatus: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Label("Multi-View", systemImage: "rectangle.split.2x2")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ForEach(viewModel.activeStreams) { stream in
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(.green).frame(width: 8, height: 8)
|
||||
Text(stream.label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BroadcastSelection: Identifiable {
|
||||
let id: String
|
||||
let broadcast: Broadcast
|
||||
let game: Game
|
||||
|
||||
init(broadcast: Broadcast, game: Game) {
|
||||
self.id = broadcast.id
|
||||
self.broadcast = broadcast
|
||||
self.game = game
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user