Aggiunge UI test sul simulatore (hub, lingue, wizard, diretta) e identificatori di accessibilità per poterli ripetere su collaudo e produzione. Co-authored-by: Cursor <cursoragent@cursor.com>
678 lines
26 KiB
Swift
678 lines
26 KiB
Swift
import SwiftUI
|
||
|
||
private let sideToolbarWidth: CGFloat = 44
|
||
private let iconButtonSize: CGFloat = 36
|
||
private let scoreButtonHeight: CGFloat = 32
|
||
|
||
private enum SideConfirmAction {
|
||
case shareLive
|
||
case shareRegia
|
||
case advancePeriod
|
||
case pauseOrResume
|
||
case toggleMute
|
||
case terminate
|
||
}
|
||
|
||
struct BroadcastControlsOverlay: View {
|
||
let controlsVisible: Bool
|
||
let onToggleControls: () -> Void
|
||
let statusText: String
|
||
let statusColor: Color
|
||
let cableConnected: Bool
|
||
let isPaused: Bool
|
||
let homeName: String
|
||
let awayName: String
|
||
let homeAccentColor: Color
|
||
let awayAccentColor: Color
|
||
let homeLogoUrl: String?
|
||
let awayLogoUrl: String?
|
||
let score: ScoreState
|
||
let boardType: String
|
||
let pointsTarget: Int
|
||
let onPointHome: () -> Void
|
||
let onPointAway: () -> Void
|
||
let onMinusHome: () -> Void
|
||
let onMinusAway: () -> Void
|
||
var onPoint2Home: (() -> Void)?
|
||
var onPoint3Home: (() -> Void)?
|
||
var onPoint2Away: (() -> Void)?
|
||
var onPoint3Away: (() -> Void)?
|
||
var onCloseSet: (() -> Void)?
|
||
var onAdvancePeriod: (() -> Void)?
|
||
let onPauseOrResume: () -> Void
|
||
let onToggleAudioMute: () -> Void
|
||
let audioMuted: Bool
|
||
let onTerminate: () -> Void
|
||
let onShareLive: () -> Void
|
||
let onShareRegia: () -> Void
|
||
let shareLiveEnabled: Bool
|
||
let fps: Int
|
||
let targetFps: Int
|
||
let bitrateKbps: Int
|
||
let networkType: String
|
||
let deviceHealth: DeviceHealth
|
||
var minQualityPreset: String = StreamQualityLadder.auto
|
||
var sessionQualityPreset: String = "720p_30_2.5mbps"
|
||
var onSelectMinQuality: (String) -> Void = { _ in }
|
||
|
||
@State private var pendingConfirm: SideConfirmAction?
|
||
@State private var showMinQualityPicker = false
|
||
|
||
var body: some View {
|
||
Color.clear
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.contentShape(Rectangle())
|
||
.overlay(alignment: .topLeading) {
|
||
MatchStatusBadge(
|
||
text: statusText,
|
||
backgroundColor: MatchColors.background.opacity(0.78),
|
||
textColor: statusColor
|
||
)
|
||
.padding(.leading, 8)
|
||
.padding(.top, 4)
|
||
}
|
||
.overlay(alignment: .topTrailing) {
|
||
VStack(alignment: .trailing, spacing: 6) {
|
||
SideIconButton(
|
||
systemName: controlsVisible ? "eye.slash.fill" : "eye.fill",
|
||
accessibilityLabel: controlsVisible ? L10n.t("broadcast.hide.controls.cd") : L10n.t("broadcast.show.controls.cd"),
|
||
action: onToggleControls
|
||
)
|
||
BroadcastTelemetryPanel(
|
||
cableConnected: cableConnected,
|
||
fps: fps,
|
||
targetFps: targetFps,
|
||
bitrateKbps: bitrateKbps,
|
||
networkType: networkType,
|
||
deviceHealth: deviceHealth,
|
||
audioMuted: audioMuted,
|
||
minQualityLabel: StreamQualityLadder.displayName(
|
||
id: minQualityPreset,
|
||
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||
)
|
||
)
|
||
}
|
||
.padding(.trailing, 8)
|
||
.padding(.top, 4)
|
||
}
|
||
.overlay(alignment: .leading) {
|
||
if controlsVisible {
|
||
leftToolbar
|
||
.padding(.leading, 6)
|
||
}
|
||
}
|
||
.overlay(alignment: .trailing) {
|
||
if controlsVisible {
|
||
rightToolbar
|
||
.padding(.trailing, 6)
|
||
}
|
||
}
|
||
.overlay(alignment: .bottom) {
|
||
if controlsVisible {
|
||
scoreControlsRow
|
||
.padding(.horizontal, sideToolbarWidth)
|
||
.padding(.bottom, 8)
|
||
}
|
||
}
|
||
.overlay {
|
||
if showMinQualityPicker {
|
||
MinQualityPickerOverlay(
|
||
selectedId: minQualityPreset,
|
||
sessionQualityPreset: sessionQualityPreset,
|
||
onSelect: { id in
|
||
showMinQualityPicker = false
|
||
onSelectMinQuality(id)
|
||
},
|
||
onDismiss: { showMinQualityPicker = false }
|
||
)
|
||
}
|
||
}
|
||
.alert(
|
||
sideConfirmTitle,
|
||
isPresented: Binding(
|
||
get: { pendingConfirm != nil },
|
||
set: { if !$0 { pendingConfirm = nil } }
|
||
)
|
||
) {
|
||
Button(L10n.t("action.cancel"), role: .cancel) { pendingConfirm = nil }
|
||
if pendingConfirm == .terminate {
|
||
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: performPendingConfirm)
|
||
} else {
|
||
Button(L10n.t("action.confirm"), action: performPendingConfirm)
|
||
}
|
||
} message: {
|
||
Text(sideConfirmMessage)
|
||
}
|
||
}
|
||
|
||
private var sideConfirmTitle: String {
|
||
switch pendingConfirm {
|
||
case .shareLive: return L10n.t("broadcast.share.live.title")
|
||
case .shareRegia: return L10n.t("broadcast.share.regia.title")
|
||
case .advancePeriod: return L10n.t("broadcast.advance.period.title")
|
||
case .pauseOrResume:
|
||
return isPaused ? L10n.t("broadcast.resume.title") : L10n.t("broadcast.pause.title")
|
||
case .toggleMute:
|
||
return audioMuted ? L10n.t("broadcast.unmute.title") : L10n.t("broadcast.mute.title")
|
||
case .terminate: return L10n.t("broadcast.terminate.title")
|
||
case .none: return ""
|
||
}
|
||
}
|
||
|
||
private var sideConfirmMessage: String {
|
||
switch pendingConfirm {
|
||
case .shareLive: return L10n.t("broadcast.share.live.message")
|
||
case .shareRegia: return L10n.t("broadcast.share.regia.message")
|
||
case .advancePeriod: return L10n.t("broadcast.advance.period.message")
|
||
case .pauseOrResume:
|
||
return isPaused ? L10n.t("broadcast.resume.message") : L10n.t("broadcast.pause.message")
|
||
case .toggleMute:
|
||
return audioMuted ? L10n.t("broadcast.unmute.message") : L10n.t("broadcast.mute.message")
|
||
case .terminate: return L10n.t("broadcast.terminate.message")
|
||
case .none: return ""
|
||
}
|
||
}
|
||
|
||
private func performPendingConfirm() {
|
||
guard let action = pendingConfirm else { return }
|
||
pendingConfirm = nil
|
||
switch action {
|
||
case .shareLive: onShareLive()
|
||
case .shareRegia: onShareRegia()
|
||
case .advancePeriod: onAdvancePeriod?()
|
||
case .pauseOrResume: onPauseOrResume()
|
||
case .toggleMute: onToggleAudioMute()
|
||
case .terminate: onTerminate()
|
||
}
|
||
}
|
||
|
||
private var leftToolbar: some View {
|
||
VStack(spacing: 6) {
|
||
SideIconButton(
|
||
systemName: "square.and.arrow.up",
|
||
accessibilityLabel: L10n.t("broadcast.share.live.cd"),
|
||
action: { pendingConfirm = .shareLive },
|
||
enabled: shareLiveEnabled
|
||
)
|
||
.accessibilityIdentifier("broadcast.share")
|
||
SideIconButton(
|
||
systemName: "video.fill",
|
||
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
||
action: { pendingConfirm = .shareRegia }
|
||
)
|
||
.accessibilityIdentifier("broadcast.regia")
|
||
SideIconButton(
|
||
systemName: "slider.horizontal.3",
|
||
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
|
||
action: { showMinQualityPicker = true }
|
||
)
|
||
.accessibilityIdentifier("broadcast.abr")
|
||
if let onCloseSet {
|
||
SideIconButton(
|
||
systemName: "checkmark",
|
||
accessibilityLabel: L10n.t("score.action.close.set"),
|
||
action: onCloseSet
|
||
)
|
||
}
|
||
if onAdvancePeriod != nil {
|
||
SideIconButton(
|
||
systemName: "forward.end.fill",
|
||
accessibilityLabel: L10n.t("broadcast.next.period.cd"),
|
||
action: { pendingConfirm = .advancePeriod }
|
||
)
|
||
}
|
||
}
|
||
.frame(width: sideToolbarWidth)
|
||
.allowsHitTesting(true)
|
||
}
|
||
|
||
private var rightToolbar: some View {
|
||
VStack(spacing: 6) {
|
||
SideIconButton(
|
||
systemName: isPaused ? "play.fill" : "pause.fill",
|
||
accessibilityLabel: isPaused ? L10n.t("broadcast.resume.cd") : L10n.t("broadcast.pause.cd"),
|
||
action: { pendingConfirm = .pauseOrResume },
|
||
highlighted: isPaused
|
||
)
|
||
.accessibilityIdentifier("broadcast.pause")
|
||
SideIconButton(
|
||
systemName: audioMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||
accessibilityLabel: audioMuted ? L10n.t("broadcast.unmute.cd") : L10n.t("broadcast.mute.cd"),
|
||
action: { pendingConfirm = .toggleMute },
|
||
highlighted: audioMuted
|
||
)
|
||
.accessibilityIdentifier("broadcast.mute")
|
||
SideIconButton(
|
||
systemName: "stop.fill",
|
||
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
|
||
action: { pendingConfirm = .terminate },
|
||
danger: true
|
||
)
|
||
.accessibilityIdentifier("broadcast.terminate")
|
||
}
|
||
}
|
||
|
||
private var scoreControlsRow: some View {
|
||
HStack(alignment: .bottom, spacing: 0) {
|
||
TeamScoreColumn(
|
||
teamLabel: L10n.t("broadcast.team.home.label"),
|
||
teamName: homeName,
|
||
accentColor: homeAccentColor,
|
||
logoUrl: homeLogoUrl,
|
||
points: score.homePoints,
|
||
onPlus: onPointHome,
|
||
onMinus: onMinusHome,
|
||
onPlus2: onPoint2Home,
|
||
onPlus3: onPoint3Home,
|
||
showBasketButtons: boardType == "basket",
|
||
alignEnd: false
|
||
)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
ScoreCenterPanel(score: score, boardType: boardType, pointsTarget: pointsTarget)
|
||
|
||
TeamScoreColumn(
|
||
teamLabel: L10n.t("broadcast.team.away.label"),
|
||
teamName: awayName,
|
||
accentColor: awayAccentColor,
|
||
logoUrl: awayLogoUrl,
|
||
points: score.awayPoints,
|
||
onPlus: onPointAway,
|
||
onMinus: onMinusAway,
|
||
onPlus2: onPoint2Away,
|
||
onPlus3: onPoint3Away,
|
||
showBasketButtons: boardType == "basket",
|
||
alignEnd: true
|
||
)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct MinQualityPickerOverlay: View {
|
||
let selectedId: String
|
||
let sessionQualityPreset: String
|
||
let onSelect: (String) -> Void
|
||
let onDismiss: () -> Void
|
||
|
||
private var options: [String] {
|
||
StreamQualityLadder.availableIds(sessionQualityPreset: sessionQualityPreset)
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
Color.black.opacity(0.55)
|
||
.ignoresSafeArea()
|
||
.onTapGesture(perform: onDismiss)
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text(L10n.t("broadcast.min.quality.title"))
|
||
.font(.headline)
|
||
.foregroundStyle(.white)
|
||
Spacer()
|
||
Button(L10n.t("action.cancel"), action: onDismiss)
|
||
.foregroundStyle(MatchColors.textSecondary)
|
||
}
|
||
Text(L10n.t("broadcast.min.quality.hint"))
|
||
.font(.footnote)
|
||
.foregroundStyle(MatchColors.textSecondary)
|
||
HStack(spacing: 8) {
|
||
ForEach(options, id: \.self) { id in
|
||
let selected = id == selectedId
|
||
Button {
|
||
onSelect(id)
|
||
} label: {
|
||
VStack(spacing: 4) {
|
||
Image(systemName: "checkmark")
|
||
.font(.system(size: 12, weight: .bold))
|
||
.foregroundStyle(selected ? MatchColors.accentYellow : .clear)
|
||
Text(
|
||
StreamQualityLadder.displayName(
|
||
id: id,
|
||
autoLabel: L10n.t("broadcast.min.quality.auto")
|
||
)
|
||
)
|
||
.font(.system(size: 13, weight: selected ? .semibold : .medium))
|
||
.foregroundStyle(selected ? MatchColors.accentYellow : .white)
|
||
.multilineTextAlignment(.center)
|
||
.lineLimit(2)
|
||
.minimumScaleFactor(0.8)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 12)
|
||
.background(
|
||
selected
|
||
? MatchColors.accentYellow.opacity(0.16)
|
||
: MatchColors.surfaceElevated,
|
||
in: RoundedRectangle(cornerRadius: 12)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.stroke(selected ? MatchColors.accentYellow : MatchColors.outline, lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
.padding(16)
|
||
.frame(maxWidth: 720)
|
||
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 16))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.stroke(MatchColors.outline, lineWidth: 1)
|
||
)
|
||
.padding(.horizontal, 24)
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct BroadcastTelemetryPanel: View {
|
||
let cableConnected: Bool
|
||
let fps: Int
|
||
let targetFps: Int
|
||
let bitrateKbps: Int
|
||
let networkType: String
|
||
let deviceHealth: DeviceHealth
|
||
let audioMuted: Bool
|
||
let minQualityLabel: String
|
||
|
||
var body: some View {
|
||
VStack(alignment: .trailing, spacing: 2) {
|
||
Text(cableConnected ? L10n.t("broadcast.scoreboard.connected") : L10n.t("broadcast.scoreboard.offline"))
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(cableConnected ? MatchColors.successGreen : MatchColors.textSecondary)
|
||
let fpsLabel = fps > 0 ? "\(fps) fps" : "— fps"
|
||
let targetSuffix = targetFps > 0 ? " / \(targetFps)" : ""
|
||
Text(fpsLabel + targetSuffix)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(.white)
|
||
if bitrateKbps > 0 {
|
||
Text(formatBitrateKbps(bitrateKbps))
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(MatchColors.textSecondary)
|
||
}
|
||
Text(minQualityLabel)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(MatchColors.accentYellow)
|
||
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(MatchColors.textSecondary)
|
||
if audioMuted {
|
||
Text(L10n.t("broadcast.audio.muted.label"))
|
||
.font(.system(size: 11, weight: .bold))
|
||
.foregroundStyle(MatchColors.accentYellow)
|
||
}
|
||
ThermalIndicator(state: deviceHealth.thermalState)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(MatchColors.background.opacity(0.78), in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
}
|
||
|
||
private struct ThermalIndicator: View {
|
||
let state: ThermalState
|
||
|
||
private var color: Color {
|
||
switch state {
|
||
case .nominal: return MatchColors.successGreen
|
||
case .fair: return MatchColors.accentYellow
|
||
case .serious: return Color.orange
|
||
case .critical: return MatchColors.primaryRed
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
let warningBg = state >= .fair ? color.opacity(0.18) : Color.clear
|
||
HStack(spacing: 4) {
|
||
Text(state.indicatorSymbol)
|
||
.font(.system(size: 11))
|
||
Text(state.displayLabel)
|
||
.font(.system(size: 11, weight: state >= .fair ? .bold : .medium))
|
||
.foregroundStyle(color)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.85)
|
||
}
|
||
.padding(.horizontal, 4)
|
||
.padding(.vertical, 1)
|
||
.background(warningBg, in: RoundedRectangle(cornerRadius: 4))
|
||
}
|
||
}
|
||
|
||
private struct ScoreCenterPanel: View {
|
||
let score: ScoreState
|
||
let boardType: String
|
||
let pointsTarget: Int
|
||
|
||
var body: some View {
|
||
VStack(spacing: 2) {
|
||
Text("\(score.homePoints) - \(score.awayPoints)")
|
||
.font(MatchTypography.titleMedium)
|
||
.foregroundStyle(.white)
|
||
.fontWeight(.bold)
|
||
switch boardType {
|
||
case "basket", "timed":
|
||
Text(score.periodLabel ?? (boardType == "basket" ? "Q\(score.period)" : L10n.t("broadcast.period.label.timed", score.period)))
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(MatchColors.textSecondary)
|
||
case "generic":
|
||
EmptyView()
|
||
default:
|
||
Text(L10n.t("broadcast.set.progress", score.currentSet, pointsTarget))
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(MatchColors.textSecondary)
|
||
if score.homeSets > 0 || score.awaySets > 0 {
|
||
Text(L10n.t("broadcast.sets.won", score.homeSets, score.awaySets))
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(MatchColors.textSecondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.bottom, 8)
|
||
}
|
||
}
|
||
|
||
private struct TeamScoreColumn: View {
|
||
let teamLabel: String
|
||
let teamName: String
|
||
let accentColor: Color
|
||
let logoUrl: String?
|
||
let points: Int
|
||
let onPlus: () -> Void
|
||
let onMinus: () -> Void
|
||
var onPlus2: (() -> Void)?
|
||
var onPlus3: (() -> Void)?
|
||
let showBasketButtons: Bool
|
||
let alignEnd: Bool
|
||
|
||
var body: some View {
|
||
VStack(alignment: alignEnd ? .trailing : .leading, spacing: 0) {
|
||
TeamIdentityRow(
|
||
teamLabel: teamLabel,
|
||
teamName: teamName,
|
||
accentColor: accentColor,
|
||
logoUrl: logoUrl,
|
||
alignEnd: alignEnd
|
||
)
|
||
Spacer().frame(height: 6)
|
||
Text("\(points)")
|
||
.font(MatchTypography.headlineMedium)
|
||
.foregroundStyle(accentColor)
|
||
.fontWeight(.bold)
|
||
.padding(.vertical, 4)
|
||
Spacer().frame(height: 4)
|
||
let teamSide = alignEnd ? L10n.t("broadcast.side.away") : L10n.t("broadcast.side.home")
|
||
HStack(spacing: 6) {
|
||
if alignEnd {
|
||
if showBasketButtons {
|
||
if let onPlus3 {
|
||
ScoreIconButton(label: "+3", tooltip: L10n.t("broadcast.tooltip.plus.side", 3, teamSide), action: onPlus3, primary: true)
|
||
}
|
||
if let onPlus2 {
|
||
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true)
|
||
}
|
||
}
|
||
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
|
||
.accessibilityIdentifier("broadcast.score.away.plus")
|
||
ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
|
||
} else {
|
||
ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
|
||
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
|
||
.accessibilityIdentifier(alignEnd ? "broadcast.score.away.plus" : "broadcast.score.home.plus")
|
||
if showBasketButtons {
|
||
if let onPlus2 {
|
||
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true)
|
||
}
|
||
if let onPlus3 {
|
||
ScoreIconButton(label: "+3", tooltip: L10n.t("broadcast.tooltip.plus.side", 3, teamSide), action: onPlus3, primary: true)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: alignEnd ? .trailing : .leading)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 8)
|
||
.background(MatchColors.background.opacity(0.72), in: RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
}
|
||
|
||
private struct TeamIdentityRow: View {
|
||
let teamLabel: String
|
||
let teamName: String
|
||
let accentColor: Color
|
||
let logoUrl: String?
|
||
let alignEnd: Bool
|
||
|
||
var body: some View {
|
||
let resolvedLogoUrl = MediaUrl.resolve(logoUrl)
|
||
HStack(alignment: .center, spacing: 0) {
|
||
if alignEnd {
|
||
VStack(alignment: .trailing, spacing: 0) {
|
||
Text(teamLabel)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(MatchColors.textSecondary)
|
||
.kerning(1)
|
||
Text(teamName)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(1)
|
||
.multilineTextAlignment(.trailing)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
|
||
Spacer().frame(width: 8)
|
||
TeamLogoThumbnail(url: url)
|
||
}
|
||
Spacer().frame(width: 6)
|
||
TeamColorBar(color: accentColor)
|
||
} else {
|
||
TeamColorBar(color: accentColor)
|
||
if let resolvedLogoUrl, let url = URL(string: resolvedLogoUrl) {
|
||
Spacer().frame(width: 6)
|
||
TeamLogoThumbnail(url: url)
|
||
}
|
||
Spacer().frame(width: 8)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text(teamLabel)
|
||
.font(.system(size: 11, weight: .medium))
|
||
.foregroundStyle(MatchColors.textSecondary)
|
||
.kerning(1)
|
||
Text(teamName)
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(.white)
|
||
.lineLimit(1)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct TeamColorBar: View {
|
||
let color: Color
|
||
|
||
var body: some View {
|
||
RoundedRectangle(cornerRadius: 2)
|
||
.fill(color)
|
||
.frame(width: 4, height: 36)
|
||
}
|
||
}
|
||
|
||
private struct TeamLogoThumbnail: View {
|
||
let url: URL
|
||
|
||
var body: some View {
|
||
AsyncImage(url: url) { phase in
|
||
switch phase {
|
||
case .success(let image):
|
||
image.resizable().scaledToFill()
|
||
default:
|
||
Color.clear
|
||
}
|
||
}
|
||
.frame(width: 32, height: 32)
|
||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||
.overlay(RoundedRectangle(cornerRadius: 6).stroke(MatchColors.outline, lineWidth: 1))
|
||
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
|
||
}
|
||
}
|
||
|
||
private struct SideIconButton: View {
|
||
let systemName: String
|
||
let accessibilityLabel: String
|
||
let action: () -> Void
|
||
var enabled: Bool = true
|
||
var highlighted: Bool = false
|
||
var danger: Bool = false
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
Image(systemName: systemName)
|
||
.font(.system(size: 16, weight: .semibold))
|
||
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
|
||
.frame(width: iconButtonSize, height: iconButtonSize)
|
||
.contentShape(Rectangle())
|
||
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(!enabled)
|
||
.accessibilityLabel(accessibilityLabel)
|
||
}
|
||
|
||
private var backgroundColor: Color {
|
||
if danger { return MatchColors.primaryRed.opacity(0.88) }
|
||
if highlighted { return MatchColors.successGreen.opacity(0.55) }
|
||
return MatchColors.background.opacity(0.78)
|
||
}
|
||
}
|
||
|
||
private struct ScoreIconButton: View {
|
||
let label: String
|
||
let tooltip: String
|
||
let action: () -> Void
|
||
var primary: Bool = false
|
||
|
||
var body: some View {
|
||
Button(action: action) {
|
||
Text(label)
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.frame(width: label == "+1" ? 40 : scoreButtonHeight, height: scoreButtonHeight)
|
||
.background(primary ? MatchColors.primaryRed : MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 6))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(tooltip)
|
||
}
|
||
}
|
||
|
||
private func formatBitrateKbps(_ kbps: Int) -> String {
|
||
if kbps >= 1000 {
|
||
return String(format: "%.1f Mbps", Double(kbps) / 1000.0)
|
||
}
|
||
return "\(kbps) kbps"
|
||
}
|