Files
MLBApp/mlbTVOS/Views/GameListView.swift
Trey t fda809fd2f Add iOS/iPad target with platform-adaptive UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:30:28 -05:00

661 lines
22 KiB
Swift

import SwiftUI
struct StreamOptionsSheet: View {
let game: Game
var onWatch: ((BroadcastSelection) -> Void)?
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.dismiss) private var dismiss
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
private var hasLinescore: Bool {
!game.status.isScheduled && (game.linescore?.hasData ?? false)
}
private var canAddMoreStreams: Bool {
viewModel.activeStreams.count < 4
}
private var usesStackedLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
private var horizontalPadding: CGFloat {
usesStackedLayout ? 20 : 56
}
private var verticalPadding: CGFloat {
usesStackedLayout ? 24 : 42
}
private var awayPitcherName: String? {
guard let pitchers = game.pitchers else { return nil }
let parts = pitchers.components(separatedBy: " vs ")
return parts.first
}
private var homePitcherName: String? {
guard let pitchers = game.pitchers else { return nil }
let parts = pitchers.components(separatedBy: " vs ")
return parts.count > 1 ? parts.last : nil
}
var body: some View {
ScrollView {
VStack(spacing: 28) {
ViewThatFits {
HStack(alignment: .top, spacing: 28) {
matchupColumn
.frame(maxWidth: .infinity, alignment: .leading)
actionRail
.frame(width: 520, alignment: .leading)
}
VStack(alignment: .leading, spacing: 24) {
matchupColumn
actionRail
.frame(maxWidth: .infinity, alignment: .leading)
}
}
if !canAddMoreStreams {
Label(
"Multi-view is full. Remove a stream in Multi-View or Settings before adding another.",
systemImage: "rectangle.split.2x2.fill"
)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.orange)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 22)
.padding(.vertical, 18)
.background(panelBackground)
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
}
.background(sheetBackground.ignoresSafeArea())
}
@ViewBuilder
private var matchupColumn: some View {
VStack(alignment: .leading, spacing: 20) {
headerBar
featuredTeamRow(
team: game.awayTeam,
color: awayColor,
pitcherURL: game.awayPitcherHeadshotURL,
pitcherName: awayPitcherName
)
scorePanel
featuredTeamRow(
team: game.homeTeam,
color: homeColor,
pitcherURL: game.homePitcherHeadshotURL,
pitcherName: homePitcherName
)
if hasLinescore,
let linescore = game.linescore {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Linescore")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
Spacer()
Text(linescoreStatusText)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.58))
}
LinescoreView(
linescore: linescore,
awayCode: game.awayTeam.code,
homeCode: game.homeTeam.code
)
}
.padding(22)
.background(panelBackground)
}
GameCenterView(game: game)
}
}
@ViewBuilder
private var headerBar: some View {
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text((game.gameType ?? "Game Center").uppercased())
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.58))
.kerning(1.8)
if let venue = game.venue {
Label(venue, systemImage: "mappin.and.ellipse")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.82))
}
}
Spacer()
HStack(spacing: 10) {
if game.isBlackedOut {
chip(title: "BLACKOUT", color: .red)
} else if !game.broadcasts.isEmpty {
chip(title: "\(game.broadcasts.count) FEED\(game.broadcasts.count == 1 ? "" : "S")", color: .blue)
}
statusChip
}
}
}
@ViewBuilder
private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View {
HStack(spacing: 18) {
TeamLogoView(team: team, size: 72)
.frame(width: 78, height: 78)
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 10) {
Text(team.code)
.font(.system(size: 30, weight: .black, design: .rounded))
.foregroundStyle(.white)
if let record = team.record {
Text(record)
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundStyle(.white.opacity(0.72))
.padding(.horizontal, 11)
.padding(.vertical, 6)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
}
Text(team.displayName)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.96))
.lineLimit(1)
.minimumScaleFactor(0.82)
if let standing = team.standingSummary {
Text(standing)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.56))
.lineLimit(1)
}
}
Spacer(minLength: 14)
if pitcherURL != nil || pitcherName != nil {
HStack(spacing: 12) {
if pitcherURL != nil {
PitcherHeadshotView(
url: pitcherURL,
teamCode: team.code,
name: nil,
size: 46
)
}
VStack(alignment: .trailing, spacing: 4) {
Text("Probable")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.42))
Text(pitcherName ?? "TBD")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.white.opacity(0.84))
.lineLimit(1)
}
}
}
}
.padding(.horizontal, 22)
.padding(.vertical, 18)
.background {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(teamPanelBackground(color: color))
}
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var scorePanel: some View {
VStack(spacing: 12) {
if let summary = scoreSummaryText {
Text(summary)
.font(.system(size: 72, weight: .black, design: .rounded))
.foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText())
} else {
Text(game.status.label)
.font(.system(size: 52, weight: .black, design: .rounded))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
}
Text(statusHeadline)
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(statusHeadlineColor)
if let detail = centerDetailText {
Text(detail)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.62))
.lineLimit(2)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 26)
.background {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.white.opacity(0.06))
}
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var actionRail: some View {
VStack(alignment: .leading, spacing: 18) {
actionRailHeader
if !game.broadcasts.isEmpty {
ForEach(game.broadcasts) { broadcast in
broadcastCard(broadcast)
}
} else if game.isBlackedOut {
blackoutCard
} else if game.status.isScheduled {
scheduledStateCard
} else {
fallbackActionsCard
}
}
}
@ViewBuilder
private var actionRailHeader: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Watch Options")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(actionRailSubtitle)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.68))
.lineLimit(2)
}
.padding(22)
.background(panelBackground)
}
@ViewBuilder
private func broadcastCard(_ broadcast: Broadcast) -> some View {
let alreadyAdded = viewModel.activeStreams.contains(where: { $0.id == broadcast.id })
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
Circle()
.fill(TeamAssets.color(for: broadcast.teamCode))
.frame(width: 10, height: 10)
Text(broadcast.teamCode)
.font(.system(size: 13, weight: .black, design: .rounded))
.foregroundStyle(.white.opacity(0.78))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.white.opacity(0.08))
.clipShape(Capsule())
Text(broadcast.name)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
Spacer()
if alreadyAdded {
chip(title: "ADDED", color: .green)
}
}
VStack(spacing: 14) {
railActionButton(
title: "Watch Full Screen",
subtitle: "Open \(broadcast.name) in the main player",
systemImage: "play.fill",
fill: .blue.opacity(0.18)
) {
let selection = BroadcastSelection(broadcast: broadcast, game: game)
if let onWatch { onWatch(selection) } else { dismiss() }
}
railActionButton(
title: alreadyAdded ? "Already Added to Multi-View" : "Add to Multi-View",
subtitle: alreadyAdded ? "This feed is already active in the grid" : "Send \(broadcast.name) into an open tile",
systemImage: alreadyAdded ? "checkmark.circle.fill" : "plus.circle.fill",
fill: alreadyAdded ? .green.opacity(0.14) : .white.opacity(0.08),
foreground: alreadyAdded ? .green : .white,
disabled: !canAddMoreStreams || alreadyAdded
) {
viewModel.addStream(broadcast: broadcast, game: game)
dismiss()
}
}
}
.padding(22)
.background(panelBackground)
}
@ViewBuilder
private var blackoutCard: some View {
VStack(alignment: .leading, spacing: 14) {
Image(systemName: "eye.slash.fill")
.font(.system(size: 36, weight: .bold))
.foregroundStyle(.red)
Text("Blacked Out")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("This game is unavailable in your area. The matchup data is still live, but watch controls are disabled.")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.white.opacity(0.72))
.lineLimit(3)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.red.opacity(0.12))
)
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.red.opacity(0.24), lineWidth: 1)
}
}
@ViewBuilder
private var scheduledStateCard: some View {
VStack(alignment: .leading, spacing: 14) {
Label("Feeds Not Posted Yet", systemImage: "calendar")
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.white)
Text("Streams usually appear closer to first pitch. Check back around game time.")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.white.opacity(0.72))
.lineLimit(3)
if let venue = game.venue {
Text(venue)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(panelBackground)
}
@ViewBuilder
private var fallbackActionsCard: some View {
VStack(alignment: .leading, spacing: 16) {
Text("No Feeds Listed")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("You can still try building a stream from one of the team feeds.")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white.opacity(0.68))
VStack(spacing: 14) {
fallbackButton(teamCode: game.awayTeam.code)
fallbackButton(teamCode: game.homeTeam.code)
}
}
.padding(22)
.background(panelBackground)
}
@ViewBuilder
private func fallbackButton(teamCode: String) -> some View {
railActionButton(
title: "Try \(teamCode) Feed",
subtitle: "Build a fallback stream for \(teamCode)",
systemImage: "play.circle.fill",
fill: .white.opacity(0.08),
disabled: !canAddMoreStreams
) {
viewModel.addStreamByTeam(teamCode: teamCode, game: game)
dismiss()
}
}
@ViewBuilder
private func railActionButton(
title: String,
subtitle: String,
systemImage: String,
fill: Color,
foreground: Color = .white,
disabled: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(alignment: .center, spacing: 14) {
Image(systemName: systemImage)
.font(.system(size: 20, weight: .bold))
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundStyle(foreground)
.fixedSize(horizontal: false, vertical: true)
Text(subtitle)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(foreground.opacity(0.64))
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
.padding(.horizontal, 20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(fill)
)
}
.platformCardStyle()
.disabled(disabled)
}
@ViewBuilder
private var statusChip: some View {
switch game.status {
case .live(let inning):
HStack(spacing: 8) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Text(inning ?? "LIVE")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
}
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.red.opacity(0.16))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.white.opacity(0.1))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.system(size: 15, weight: .black, design: .rounded))
.foregroundStyle(.white)
.padding(.horizontal, 15)
.padding(.vertical, 10)
.background(.white.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
@ViewBuilder
private func chip(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private var scoreSummaryText: String? {
guard let away = game.awayTeam.score, let home = game.homeTeam.score else { return nil }
return "\(away) - \(home)"
}
private var statusHeadline: String {
switch game.status {
case .scheduled:
return "First Pitch"
case .live(let inning):
return inning ?? "Live"
case .final_:
return "Final"
case .unknown:
return "Game Day"
}
}
private var statusHeadlineColor: Color {
switch game.status {
case .live:
return .red.opacity(0.92)
case .scheduled:
return .blue.opacity(0.92)
case .final_:
return .white.opacity(0.82)
case .unknown:
return .white.opacity(0.72)
}
}
private var centerDetailText: String? {
if game.status.isScheduled {
return game.pitchers ?? game.venue
}
return game.pitchers ?? game.venue
}
private var actionRailSubtitle: String {
if game.isBlackedOut {
return "Watch controls are unavailable for this matchup."
}
if !game.broadcasts.isEmpty {
return "Pick a feed to watch full screen or send it into Multi-View."
}
if game.status.isScheduled {
return "Streams normally appear closer to game time."
}
return "Try a team feed fallback for this matchup."
}
private var linescoreStatusText: String {
if let inning = game.currentInningDisplay, !inning.isEmpty {
return inning.uppercased()
}
if game.isFinal {
return "FINAL"
}
return "GAME"
}
private func teamPanelBackground(color: Color) -> some ShapeStyle {
LinearGradient(
colors: [
color.opacity(0.22),
.white.opacity(0.05)
],
startPoint: .leading,
endPoint: .trailing
)
}
@ViewBuilder
private var panelBackground: some View {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.black.opacity(0.22))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
}
@ViewBuilder
private var sheetBackground: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.05, green: 0.06, blue: 0.1),
Color(red: 0.08, green: 0.06, blue: 0.09)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(awayColor.opacity(0.16))
.frame(width: 520, height: 520)
.blur(radius: 90)
.offset(x: -360, y: -220)
Circle()
.fill(homeColor.opacity(0.18))
.frame(width: 520, height: 520)
.blur(radius: 92)
.offset(x: 420, y: 120)
}
}
}