Initial commit

This commit is contained in:
Trey t
2026-03-26 15:37:31 -05:00
commit bae265b132
41 changed files with 8596 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import SwiftUI
struct LinescoreView: View {
let linescore: StatsLinescore
let awayCode: String
let homeCode: String
private var totalInnings: Int {
linescore.scheduledInnings ?? 9
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 0) {
Text("")
.frame(width: 70, alignment: .leading)
ForEach(1...totalInnings, id: \.self) { inning in
let isCurrent = inning == linescore.currentInning
HStack(spacing: 0) {
if inning > 1 && inning % 3 == 1 {
Divider()
.frame(width: 1, height: 18)
.background(.secondary.opacity(0.3))
.padding(.trailing, 4)
}
Text("\(inning)")
.font(.callout.weight(.semibold).monospacedDigit())
.foregroundStyle(isCurrent ? .primary : .secondary)
.frame(width: 44)
}
}
Divider().frame(width: 1, height: 20).padding(.horizontal, 6)
ForEach(["R", "H", "E"], id: \.self) { label in
Text(label)
.font(.callout.weight(.bold).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 48)
}
}
.padding(.vertical, 10)
.background(.ultraThinMaterial)
Divider()
teamRow(code: awayCode, innings: linescore.innings ?? [], side: .away, totals: linescore.teams?.away)
Divider()
teamRow(code: homeCode, innings: linescore.innings ?? [], side: .home, totals: linescore.teams?.home)
}
.clipShape(RoundedRectangle(cornerRadius: 10))
.background(.regularMaterial)
}
private enum Side { case away, home }
@ViewBuilder
private func teamRow(code: String, innings: [StatsInningScore], side: Side, totals: StatsLinescoreTotals?) -> some View {
HStack(spacing: 0) {
Text(code)
.font(.callout.weight(.bold))
.foregroundStyle(TeamAssets.color(for: code))
.frame(width: 70, alignment: .leading)
ForEach(1...totalInnings, id: \.self) { inning in
let runs = inningRuns(innings: innings, inning: inning, side: side)
let isCurrent = inning == linescore.currentInning
HStack(spacing: 0) {
if inning > 1 && inning % 3 == 1 {
Divider()
.frame(width: 1, height: 22)
.background(.secondary.opacity(0.2))
.padding(.trailing, 4)
}
Text(runs.map { "\($0)" } ?? "-")
.font(.callout.weight(runs != nil ? .semibold : .regular).monospacedDigit())
.foregroundStyle(runs == nil ? .tertiary : isCurrent ? .primary : .secondary)
.frame(width: 44)
}
}
Divider().frame(width: 1, height: 24).padding(.horizontal, 6)
Text(totals?.runs.map { "\($0)" } ?? "-")
.font(.callout.weight(.bold).monospacedDigit())
.frame(width: 48)
Text(totals?.hits.map { "\($0)" } ?? "-")
.font(.callout.weight(.medium).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 48)
Text(totals?.errors.map { "\($0)" } ?? "-")
.font(.callout.weight(.medium).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 48)
}
.padding(.vertical, 12)
}
private func inningRuns(innings: [StatsInningScore], inning: Int, side: Side) -> Int? {
guard let data = innings.first(where: { $0.num == inning }) else { return nil }
switch side {
case .away: return data.away?.runs
case .home: return data.home?.runs
}
}
}

View File

@@ -0,0 +1,66 @@
import SwiftUI
struct LiveIndicator: View {
@State private var isPulsing = false
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(.red)
.frame(width: 12, height: 12)
.scaleEffect(isPulsing ? 1.4 : 1.0)
.opacity(isPulsing ? 0.6 : 1.0)
.animation(
.easeInOut(duration: 0.8).repeatForever(autoreverses: true),
value: isPulsing
)
Text("LIVE")
.font(.subheadline.weight(.black))
.foregroundStyle(.red)
}
.onAppear { isPulsing = true }
}
}
struct StatusBadge: View {
let status: GameStatus
var body: some View {
switch status {
case .live(let inning):
HStack(spacing: 8) {
LiveIndicator()
if let inning {
Text(inning)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.red.opacity(0.15))
.clipShape(Capsule())
case .scheduled(let time):
Text(time)
.font(.body.weight(.bold))
.foregroundStyle(.green)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.green.opacity(0.12))
.clipShape(Capsule())
case .final_:
Text("FINAL")
.font(.subheadline.weight(.bold))
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.secondary.opacity(0.12))
.clipShape(Capsule())
case .unknown:
EmptyView()
}
}
}

View File

@@ -0,0 +1,53 @@
import SwiftUI
struct PitcherHeadshotView: View {
let url: URL?
var teamCode: String?
var name: String?
var size: CGFloat = 56
var body: some View {
VStack(spacing: 6) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
fallbackImage
default:
fallbackImage
.redacted(reason: .placeholder)
}
}
.frame(width: size, height: size)
.clipShape(Circle())
.overlay(
Circle()
.strokeBorder(
teamCode.map { TeamAssets.color(for: $0) } ?? .gray,
lineWidth: 2
)
)
if let name {
Text(name)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
@ViewBuilder
private var fallbackImage: some View {
ZStack {
Circle()
.fill(.gray.opacity(0.3))
Image(systemName: "person.fill")
.font(.system(size: size * 0.4))
.foregroundStyle(.secondary)
}
}
}

View File

@@ -0,0 +1,67 @@
import SwiftUI
struct ScoreOverlayView: View {
let game: Game
var body: some View {
// Don't show for non-game streams (e.g., MLB Network)
if game.id == "MLBN" {
EmptyView()
} else {
HStack(spacing: 10) {
// Away
HStack(spacing: 6) {
Circle()
.fill(TeamAssets.color(for: game.awayTeam.code))
.frame(width: 8, height: 8)
Text(game.awayTeam.code)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if !game.status.isScheduled, let score = game.awayTeam.score {
Text("\(score)")
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
.foregroundStyle(.white)
}
}
Text("-")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.4))
// Home
HStack(spacing: 6) {
if !game.status.isScheduled, let score = game.homeTeam.score {
Text("\(score)")
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
.foregroundStyle(.white)
}
Text(game.homeTeam.code)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Circle()
.fill(TeamAssets.color(for: game.homeTeam.code))
.frame(width: 8, height: 8)
}
// Inning/status
if game.isLive, let inning = game.currentInningDisplay {
Text(inning)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.6))
.padding(.leading, 4)
} else if game.isFinal {
Text("F")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white.opacity(0.5))
.padding(.leading, 4)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.black.opacity(0.7))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}

View File

@@ -0,0 +1,167 @@
import SwiftUI
import UIKit
struct ScoresTickerView: View {
var gamesOverride: [Game]? = nil
@Environment(GamesViewModel.self) private var viewModel
private var entries: [Game] {
gamesOverride ?? viewModel.games
}
var body: some View {
if !entries.isEmpty {
TickerMarqueeView(text: tickerText)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: 22)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.72))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
)
.accessibilityHidden(true)
}
}
private var tickerText: String {
entries.map(tickerSummary(for:)).joined(separator: " | ")
}
private func tickerSummary(for game: Game) -> String {
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
if game.isLive, let linescore = game.linescore {
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
let outs = linescore.outs ?? 0
return "\(matchup) \(state) \(inning.uppercased())\(outs) OUT\(outs == 1 ? "" : "S")"
}
if game.isFinal {
return "\(matchup) FINAL"
}
if let startTime = game.startTime {
return "\(matchup) \(startTime.uppercased())"
}
return "\(matchup) \(game.status.label.uppercased())"
}
}
private struct TickerMarqueeView: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> TickerMarqueeContainerView {
let view = TickerMarqueeContainerView()
view.setText(text)
return view
}
func updateUIView(_ uiView: TickerMarqueeContainerView, context: Context) {
uiView.setText(text)
}
}
private final class TickerMarqueeContainerView: UIView {
private let trackView = UIView()
private let primaryLabel = UILabel()
private let secondaryLabel = UILabel()
private let spacing: CGFloat = 48
private let pointsPerSecond: CGFloat = 64
private var currentText = ""
private var previousBoundsWidth: CGFloat = 0
private var previousContentWidth: CGFloat = 0
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = true
isUserInteractionEnabled = false
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
[primaryLabel, secondaryLabel].forEach { label in
label.font = roundedFont
label.textColor = UIColor.white.withAlphaComponent(0.92)
label.numberOfLines = 1
label.lineBreakMode = .byClipping
trackView.addSubview(label)
}
addSubview(trackView)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setText(_ text: String) {
guard currentText != text else { return }
currentText = text
primaryLabel.text = text
secondaryLabel.text = text
previousContentWidth = 0
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
let contentHeight = bounds.height
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
let cycleWidth = contentWidth + spacing
if contentWidth <= bounds.width {
trackView.layer.removeAllAnimations()
secondaryLabel.isHidden = true
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
primaryLabel.textAlignment = .left
previousBoundsWidth = bounds.width
previousContentWidth = contentWidth
return
}
primaryLabel.textAlignment = .left
secondaryLabel.isHidden = false
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|| abs(previousContentWidth - contentWidth) > 0.5
|| trackView.layer.animation(forKey: "tickerMarquee") == nil
previousBoundsWidth = bounds.width
previousContentWidth = contentWidth
guard shouldRestart else { return }
trackView.layer.removeAllAnimations()
trackView.transform = .identity
let animation = CABasicAnimation(keyPath: "transform.translation.x")
animation.fromValue = 0
animation.toValue = -cycleWidth
animation.duration = Double(cycleWidth / pointsPerSecond)
animation.repeatCount = .infinity
animation.timingFunction = CAMediaTimingFunction(name: .linear)
animation.isRemovedOnCompletion = false
trackView.layer.add(animation, forKey: "tickerMarquee")
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct TeamLogoView: View {
let team: TeamInfo
var size: CGFloat = 64
var body: some View {
AsyncImage(url: team.logoURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
case .failure:
fallbackLogo
default:
fallbackLogo
.redacted(reason: .placeholder)
}
}
.frame(width: size, height: size)
.overlay(
Circle()
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
)
}
@ViewBuilder
private var fallbackLogo: some View {
ZStack {
Circle()
.fill(TeamAssets.color(for: team.code).gradient)
Text(team.code.prefix(3))
.font(.system(size: size * 0.3, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
}
}
}