Aggiunge app iOS nativa con parità multi-sport e test unitari.

Porting SwiftUI da Android (auth, hub, wizard, broadcast RTMP, overlay, scoring board-aware), reload hub al ritorno da diretta, decodifica score tollerante e documentazione allineata.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-06-14 21:37:32 +02:00
co-authored by Cursor
parent b798e0ea06
commit 585332e32e
101 changed files with 7910 additions and 5 deletions
@@ -0,0 +1,488 @@
import SwiftUI
private let sideToolbarWidth: CGFloat = 44
private let iconButtonSize: CGFloat = 36
private let scoreButtonHeight: CGFloat = 32
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 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
@State private var showTerminateConfirm = false
var body: some View {
Color.clear
.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 ? "Nascondi controlli" : "Mostra controlli",
action: onToggleControls
)
BroadcastTelemetryPanel(
cableConnected: cableConnected,
fps: fps,
targetFps: targetFps,
bitrateKbps: bitrateKbps,
networkType: networkType,
deviceHealth: deviceHealth
)
}
.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)
}
}
.alert("Terminare la diretta?", isPresented: $showTerminateConfirm) {
Button("Annulla", role: .cancel) {}
Button("TERMINA", role: .destructive, action: onTerminate)
} message: {
Text("Lo streaming verrà chiuso per tutti gli spettatori.")
}
}
private var leftToolbar: some View {
VStack(spacing: 6) {
SideIconButton(
systemName: "square.and.arrow.up",
accessibilityLabel: "Condividi diretta",
action: onShareLive,
enabled: shareLiveEnabled
)
SideIconButton(
systemName: "video.fill",
accessibilityLabel: "Condividi link regia",
action: onShareRegia
)
if let onCloseSet {
SideIconButton(
systemName: "checkmark",
accessibilityLabel: "Chiudi set",
action: onCloseSet
)
}
if let onAdvancePeriod {
SideIconButton(
systemName: "forward.end.fill",
accessibilityLabel: "Periodo successivo",
action: onAdvancePeriod
)
}
}
}
private var rightToolbar: some View {
VStack(spacing: 6) {
SideIconButton(
systemName: isPaused ? "play.fill" : "pause.fill",
accessibilityLabel: isPaused ? "Riprendi diretta" : "Pausa diretta",
action: onPauseOrResume,
highlighted: isPaused
)
SideIconButton(
systemName: "stop.fill",
accessibilityLabel: "Termina diretta",
action: { showTerminateConfirm = true },
danger: true
)
}
}
private var scoreControlsRow: some View {
HStack(alignment: .bottom, spacing: 0) {
TeamScoreColumn(
teamLabel: "CASA",
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: "OSPITE",
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 BroadcastTelemetryPanel: View {
let cableConnected: Bool
let fps: Int
let targetFps: Int
let bitrateKbps: Int
let networkType: String
let deviceHealth: DeviceHealth
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
Text(cableConnected ? "Tabellone OK" : "Tabellone 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("\(networkType) · \(deviceHealth.batteryPercent)%")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
ThermalIndicator(
tempC: deviceHealth.batteryTempC,
level: deviceHealth.thermalLevel,
label: deviceHealth.thermalLabel
)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(MatchColors.background.opacity(0.78), in: RoundedRectangle(cornerRadius: 8))
}
}
private struct ThermalIndicator: View {
let tempC: Double?
let level: Int
let label: String
private var color: Color {
switch level {
case 0: return MatchColors.successGreen
case 1: return MatchColors.accentYellow
case 2: return Color.orange
default: return MatchColors.primaryRed
}
}
var body: some View {
let tempText = tempC.map { "\(Int($0))°C" } ?? "—°C"
let warningBg = level >= 1 ? color.opacity(0.18) : Color.clear
HStack(spacing: 4) {
Text(tempText)
.font(.system(size: 11, weight: level >= 1 ? .bold : .regular))
.foregroundStyle(color)
if level >= 1 {
Text(label)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(color)
}
}
.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)" : "\(score.period)° tempo"))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
case "generic":
EmptyView()
default:
Text("Set \(score.currentSet) · \(pointsTarget) pt")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
if score.homeSets > 0 || score.awaySets > 0 {
Text("Set vinti \(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 ? "ospite" : "casa"
HStack(spacing: 6) {
if alignEnd {
if showBasketButtons {
if let onPlus3 {
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
}
if let onPlus2 {
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
}
}
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
ScoreIconButton(label: "", tooltip: "Togli punto \(teamSide)", action: onMinus)
} else {
ScoreIconButton(label: "", tooltip: "Togli punto \(teamSide)", action: onMinus)
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
if showBasketButtons {
if let onPlus2 {
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
}
if let onPlus3 {
ScoreIconButton(label: "+3", tooltip: "+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)
.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"
}
@@ -0,0 +1,448 @@
import SwiftUI
import UIKit
struct BroadcastScreen: View {
@ObservedObject var container: AppContainer
@ObservedObject private var scoreController: ScoreController
@ObservedObject private var broadcastCoordinator: LiveBroadcastCoordinator
let sessionId: String
let onFinished: () -> Void
@StateObject private var permissions = BroadcastPermissions()
@StateObject private var scoreDialogHost = LiveScoreDialogHost()
init(container: AppContainer, sessionId: String, onFinished: @escaping () -> Void) {
self.container = container
self.sessionId = sessionId
self.onFinished = onFinished
_scoreController = ObservedObject(wrappedValue: container.scoreController)
_broadcastCoordinator = ObservedObject(wrappedValue: container.broadcastCoordinator)
}
@State private var session: StreamSession?
@State private var match: Match?
@State private var error: String?
@State private var loading = true
@State private var controlsVisible = true
@State private var logoReady = 0
@State private var pauseInFlight = false
@State private var deviceHealth = DeviceTelemetry.snapshot()
@State private var snackbarMessage: String?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if loading {
ProgressView().tint(MatchColors.primaryRed)
} else if !permissions.allGranted {
VStack(spacing: 12) {
Text("Consenti camera e microfono per andare in diretta")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.accentYellow)
.multilineTextAlignment(.center)
MatchPrimaryButton(label: "CONCEDI PERMESSI") {
Task { await permissions.requestAll() }
}
.padding(.horizontal, 40)
}
.padding(24)
} else if let session, let match {
LivePreviewView(engine: broadcastCoordinator.engine)
.ignoresSafeArea()
.allowsHitTesting(false)
broadcastOverlay(session: session, match: match)
.zIndex(1)
}
}
.statusBarHidden(true)
.lockLandscapeOrientation()
.onAppear {
UIDevice.current.isBatteryMonitoringEnabled = true
}
.onDisappear {
Task { await teardown() }
}
.task {
await permissions.refresh()
if !permissions.allGranted {
await permissions.requestAll()
}
}
.task(id: permissions.allGranted) {
guard permissions.allGranted else {
loading = false
return
}
await bootstrap()
}
.onChange(of: scoreController.score) { _ in updateOverlay() }
.onChange(of: broadcastCoordinator.metrics.phase) { _ in updateOverlay() }
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK") { onFinished() }
} message: {
Text(error ?? "")
}
.background {
if let match {
ScoreDialogRouter(
host: scoreDialogHost,
homeName: match.teamName,
awayName: match.opponentName
)
}
}
.overlay(alignment: .top) {
if let snackbarMessage {
Text(snackbarMessage)
.font(MatchTypography.bodyMedium)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 8))
.padding(.top, 48)
.onAppear {
Task {
try? await Task.sleep(nanoseconds: 3_000_000_000)
self.snackbarMessage = nil
}
}
}
}
}
@ViewBuilder
private func broadcastOverlay(session: StreamSession, match: Match) -> some View {
let metrics = broadcastCoordinator.metrics
let score = scoreController.score
let isPaused = session.isPaused || metrics.phase == .paused
let statusText = broadcastStatusText(isPaused: isPaused, metrics: metrics)
let statusColor = broadcastStatusColor(isPaused: isPaused, metrics: metrics)
let boardType = match.boardType.isEmpty ? score.boardType : match.boardType
let usesActionScoring = boardType == "basket" || boardType == "timed"
let usesSetScoring = boardType == "volley" || boardType == "racket"
let pointsTarget = MatchScoringRules.pointsTarget(for: match, currentSet: score.currentSet)
let shareSubject = "\(match.teamName) vs \(match.opponentName)"
BroadcastControlsOverlay(
controlsVisible: controlsVisible,
onToggleControls: { controlsVisible.toggle() },
statusText: statusText,
statusColor: statusColor,
cableConnected: container.sessionCable.connected,
isPaused: isPaused,
homeName: match.teamName,
awayName: match.opponentName,
homeAccentColor: ColorHex.swiftUIColor(match.homePrimaryColor, fallback: Color(red: 1, green: 0.176, blue: 0.176)),
awayAccentColor: ColorHex.swiftUIColor(match.opponentPrimaryColor, fallback: Color(red: 0.118, green: 0.227, blue: 0.541)),
homeLogoUrl: match.homeLogoUrl,
awayLogoUrl: match.opponentLogoUrl,
score: score,
boardType: boardType,
pointsTarget: pointsTarget,
onPointHome: {
Task {
if usesActionScoring {
scoreController.applyAction("home_point")
} else if usesSetScoring {
await scoreController.applyBoardAction("home_point")
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
} else {
scoreController.incrementHome()
}
}
},
onPointAway: {
Task {
if usesActionScoring {
scoreController.applyAction("away_point")
} else if usesSetScoring {
await scoreController.applyBoardAction("away_point")
await liveScoreActions(for: match).afterPointChange(score: scoreController.score)
} else {
scoreController.incrementAway()
}
}
},
onMinusHome: {
Task {
if usesActionScoring {
scoreController.applyAction("home_undo")
} else if usesSetScoring {
await scoreController.applyBoardAction("home_undo")
} else {
scoreController.decrementHome()
}
}
},
onMinusAway: {
Task {
if usesActionScoring {
scoreController.applyAction("away_undo")
} else if usesSetScoring {
await scoreController.applyBoardAction("away_undo")
} else {
scoreController.decrementAway()
}
}
},
onPoint2Home: boardType == "basket" ? { scoreController.applyAction("home_point_2") } : nil,
onPoint3Home: boardType == "basket" ? { scoreController.applyAction("home_point_3") } : nil,
onPoint2Away: boardType == "basket" ? { scoreController.applyAction("away_point_2") } : nil,
onPoint3Away: boardType == "basket" ? { scoreController.applyAction("away_point_3") } : nil,
onCloseSet: usesSetScoring ? {
Task { await liveScoreActions(for: match).requestCloseSet() }
} : nil,
onAdvancePeriod: usesActionScoring ? { scoreController.applyAction("advance_period") } : nil,
onPauseOrResume: {
if isPaused {
Task { await resumeStream() }
} else {
pauseStream()
}
},
onTerminate: { Task { await stopStream() } },
onShareLive: { shareLiveLink(session: session, subject: shareSubject) },
onShareRegia: { shareRegiaLink(subject: shareSubject) },
shareLiveEnabled: session.watchShareUrl() != nil,
fps: metrics.fps,
targetFps: session.targetFps,
bitrateKbps: metrics.bitrateKbps,
networkType: deviceHealth.networkType,
deviceHealth: deviceHealth
)
}
private func liveScoreActions(for match: Match) -> LiveScoreActions {
LiveScoreActions(
rules: MatchScoringContext(match: match),
scoreController: scoreController,
dialogHost: scoreDialogHost,
onStopStream: { await stopStream() }
)
}
private func broadcastStatusText(isPaused: Bool, metrics: BroadcastMetrics) -> String {
if isPaused { return "PAUSA" }
switch metrics.phase {
case .live: return "IN DIRETTA"
case .connecting: return "CONNESSIONE…"
case .reconnecting: return "RICONNESSIONE…"
case .error: return metrics.lastError ?? "ERRORE"
default: return "PREVIEW"
}
}
private func broadcastStatusColor(isPaused: Bool, metrics: BroadcastMetrics) -> Color {
if isPaused { return MatchColors.accentYellow }
switch metrics.phase {
case .live: return MatchColors.successGreen
case .error: return MatchColors.primaryRed
default: return MatchColors.accentYellow
}
}
private func shareLiveLink(session: StreamSession, subject: String) {
guard let urlString = session.watchShareUrl(), let url = URL(string: urlString) else {
snackbarMessage = "Link diretta non ancora disponibile"
return
}
presentShare(items: [url], subject: subject)
}
private func shareRegiaLink(subject: String) {
Task {
do {
let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId)
guard let url = URL(string: urlString) else { return }
presentShare(items: [url], subject: "Link regia — \(subject)")
} catch {
snackbarMessage = UserFacingError.message(for: error) ?? "Errore link regia"
}
}
}
private func presentShare(items: [Any], subject: String) {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else { return }
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
controller.setValue(subject, forKey: "subject")
root.present(controller, animated: true)
}
private func bootstrap() async {
loading = true
error = nil
await container.broadcastCoordinator.stopBroadcast()
do {
let loaded = try await container.sessionRepository.fetchSession(id: sessionId)
let loadedMatch = try await container.matchRepository.fetchMatch(matchId: loaded.matchId)
session = loaded
match = loadedMatch
container.scoreController.bind(sessionId: sessionId, initial: loaded.score)
wireCable()
if let url = loaded.rtmpIngestUrl, !url.isEmpty {
let config = broadcastConfig(for: loaded, rtmpUrl: url)
if loaded.isPaused {
try await container.broadcastCoordinator.engine.preparePreview(config: config)
} else {
try await container.broadcastCoordinator.startBroadcast(config: config)
}
}
await preloadLogos(for: loadedMatch)
startPolling()
loading = false
updateOverlay()
} catch {
if let message = UserFacingError.message(for: error) {
self.error = message
}
loading = false
}
}
private func wireCable() {
guard let token = container.tokenStore.session?.accessToken else { return }
container.sessionCable.onScoreUpdate = { [weak container] remote in
container?.scoreController.applyRemote(remote)
}
container.sessionCable.onPauseStream = { [weak container] in
Task { @MainActor in await container?.broadcastCoordinator.pauseBroadcast() }
}
container.sessionCable.onResumeStream = { [weak container] in
Task { @MainActor in
await resumeStream()
}
}
container.sessionCable.onStopStream = { [weak container] in
Task { @MainActor in await stopStream() }
}
container.sessionCable.connect(sessionId: sessionId, accessToken: token)
}
private func preloadLogos(for match: Match) async {
await OverlayLogoCache.preloadAll([match.homeLogoUrl, match.opponentLogoUrl])
logoReady += 1
}
private func updateOverlay() {
guard let match, let session else { return }
let overlayKind = OverlayKind.fromApi(match.effectiveOverlayKind)
let homeColor = ColorHex.parseColorHex(match.homePrimaryColor ?? "#FF2D2D")
let awayColor = ColorHex.parseColorHex(match.opponentPrimaryColor ?? "#1E3A8A")
let score = container.scoreController.score
let state: OverlayState
switch overlayKind {
case .none:
state = OverlayState(overlayKind: .none, watermarkVisible: false, broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus())
case .basket, .timed:
let period = score.periodLabel ?? (overlayKind == .basket ? "Q\(score.period)" : "\(score.period)° tempo")
state = OverlayState(
overlayKind: overlayKind,
compactScoreboard: CompactScoreboardState(
homeTeamName: match.teamName,
awayTeamName: match.opponentName,
homeScore: score.homePoints,
awayScore: score.awayPoints,
periodLabel: period,
homeAccentColor: homeColor,
awayAccentColor: awayColor,
homeLogoUrl: match.homeLogoUrl,
awayLogoUrl: match.opponentLogoUrl
),
broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus()
)
default:
state = OverlayState(
overlayKind: overlayKind,
scoreboard: score.toScoreboardState(
homeTeamName: match.teamName,
awayTeamName: match.opponentName,
homeAccentColor: homeColor,
awayAccentColor: awayColor,
homeLogoUrl: match.homeLogoUrl,
awayLogoUrl: match.opponentLogoUrl
),
broadcastStatus: container.broadcastCoordinator.metrics.phase.toOverlayStatus()
)
}
container.broadcastCoordinator.updateOverlay(state)
}
private func startPolling() {
Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 4_000_000_000)
if let remote = try? await container.sessionRepository.fetchSession(id: sessionId).score {
container.scoreController.applyRemote(remote)
}
}
}
Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 2_000_000_000)
deviceHealth = DeviceTelemetry.snapshot()
}
}
Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 10_000_000_000)
let health = DeviceTelemetry.snapshot()
let metrics = container.broadcastCoordinator.metrics
try? await container.sessionRepository.postTelemetry(
sessionId: sessionId,
health: health,
currentBitrate: metrics.bitrateKbps * 1000,
targetBitrate: session?.targetBitrate,
fps: metrics.fps
)
}
}
}
private func pauseStream() {
guard !pauseInFlight else { return }
pauseInFlight = true
Task {
_ = try? await container.sessionRepository.pauseSession(id: sessionId)
await container.broadcastCoordinator.pauseBroadcast()
pauseInFlight = false
}
}
private func resumeStream() async {
do {
var updated = try await container.sessionRepository.resumeSession(id: sessionId)
session = updated
guard let url = updated.rtmpIngestUrl, !url.isEmpty else { return }
let config = broadcastConfig(for: updated, rtmpUrl: url)
try await container.broadcastCoordinator.resumeBroadcast(config: config)
updated = try await container.sessionRepository.fetchSession(id: sessionId)
session = updated
} catch {
if let message = UserFacingError.message(for: error) {
self.error = message
}
}
}
private func broadcastConfig(for loaded: StreamSession, rtmpUrl: String) -> BroadcastConfig {
BroadcastConfig(
rtmpUrl: rtmpUrl,
width: 1280,
height: 720,
videoBitrate: loaded.targetBitrate,
audioBitrate: 128_000,
fps: loaded.targetFps
)
}
private func stopStream() async {
_ = try? await container.sessionRepository.stopSession(id: sessionId)
await teardown()
onFinished()
}
private func teardown() async {
container.sessionCable.disconnect()
await container.broadcastCoordinator.stopBroadcast()
}
}
@@ -0,0 +1,164 @@
import Foundation
import SwiftUI
enum ScoreDialogKind {
case setWon
case closeSetAnyway
case matchWon
}
struct ScoreDialogState: Identifiable {
let id = UUID()
let kind: ScoreDialogKind
var winnerSide: ScoringSide?
var homePoints: Int = 0
var awayPoints: Int = 0
var homeSets: Int = 0
var awaySets: Int = 0
}
@MainActor
final class LiveScoreDialogHost: ObservableObject {
@Published private(set) var pending: ScoreDialogState?
private var waiter: CheckedContinuation<Bool, Never>?
func show(_ state: ScoreDialogState) async -> Bool {
guard pending == nil else { return false }
return await withCheckedContinuation { continuation in
pending = state
waiter = continuation
}
}
func resolve(_ value: Bool) {
pending = nil
waiter?.resume(returning: value)
waiter = nil
}
}
@MainActor
final class LiveScoreActions {
private let rules: MatchScoringContext
private let scoreController: ScoreController
private let dialogHost: LiveScoreDialogHost
private let onStopStream: () async -> Void
init(
rules: MatchScoringContext,
scoreController: ScoreController,
dialogHost: LiveScoreDialogHost,
onStopStream: @escaping () async -> Void
) {
self.rules = rules
self.scoreController = scoreController
self.dialogHost = dialogHost
self.onStopStream = onStopStream
}
func afterPointChange(score: ScoreState) async {
guard rules.usesSetLogic else { return }
guard let winner = rules.setWinnerFromPoints(
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet
) else { return }
let close = await dialogHost.show(
ScoreDialogState(
kind: .setWon,
winnerSide: winner,
homePoints: score.homePoints,
awayPoints: score.awayPoints
)
)
if close {
guard await scoreController.closeSetAsync() else { return }
await afterCloseSet()
}
}
func requestCloseSet() async {
guard rules.usesSetLogic else { return }
let score = scoreController.score
let winner = rules.setWinnerFromPoints(
homePoints: score.homePoints,
awayPoints: score.awayPoints,
currentSet: score.currentSet
)
if winner == nil {
let ok = await dialogHost.show(ScoreDialogState(kind: .closeSetAnyway))
if !ok { return }
}
guard await scoreController.closeSetAsync() else { return }
await afterCloseSet()
}
private func afterCloseSet() async {
let score = scoreController.score
guard let winner = rules.matchWinner(homeSets: score.homeSets, awaySets: score.awaySets) else { return }
let stop = await dialogHost.show(
ScoreDialogState(
kind: .matchWon,
winnerSide: winner,
homeSets: score.homeSets,
awaySets: score.awaySets
)
)
if stop { await onStopStream() }
}
}
struct ScoreDialogRouter: View {
@ObservedObject var host: LiveScoreDialogHost
let homeName: String
let awayName: String
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.alert(item: dialogBinding) { dialog in
alert(for: dialog)
}
}
private var dialogBinding: Binding<ScoreDialogState?> {
Binding(
get: { host.pending },
set: { newValue in
if newValue == nil, host.pending != nil {
host.resolve(false)
}
}
)
}
private func alert(for dialog: ScoreDialogState) -> Alert {
switch dialog.kind {
case .setWon:
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
return Alert(
title: Text("Set concluso"),
message: Text("\(winner) vince il set \(dialog.homePoints)-\(dialog.awayPoints).\n\nChiudere il set e passare al successivo?"),
primaryButton: .default(Text("Chiudi set")) { host.resolve(true) },
secondaryButton: .cancel(Text("Continua a segnare")) { host.resolve(false) }
)
case .closeSetAnyway:
return Alert(
title: Text("Chiudi set"),
message: Text("Il punteggio non soddisfa ancora le regole del torneo. Chiudere il set comunque?"),
primaryButton: .default(Text("Chiudi comunque")) { host.resolve(true) },
secondaryButton: .cancel(Text("Annulla")) { host.resolve(false) }
)
case .matchWon:
let winner = dialog.winnerSide.map { scoringSideName($0, homeName: homeName, awayName: awayName) } ?? ""
return Alert(
title: Text("Partita terminata"),
message: Text("\(winner) vince la partita (\(dialog.homeSets)-\(dialog.awaySets) set).\n\nChiudere definitivamente la diretta?"),
primaryButton: .default(Text("Chiudi diretta")) { host.resolve(true) },
secondaryButton: .cancel(Text("Continua in onda")) { host.resolve(false) }
)
}
}
}
@@ -0,0 +1,40 @@
import SwiftUI
struct MatchLiveWordmark: View {
var compact: Bool = false
var showSlogan: Bool = false
var showLogo: Bool { !compact }
var body: some View {
VStack(spacing: 0) {
if showLogo {
Image("logo-white-m")
.resizable()
.scaledToFit()
.frame(width: compact ? 40 : 80, height: compact ? 40 : 80)
.padding(.bottom, 16)
}
HStack(spacing: 8) {
Text("MATCH")
.font(MatchTypography.displaySmall)
.foregroundStyle(.white)
Text("LIVE")
.font(MatchTypography.labelLarge)
.foregroundStyle(.white)
.padding(.horizontal, compact ? 8 : 12)
.padding(.vertical, compact ? 2 : 4)
.background(MatchColors.primaryRed, in: RoundedRectangle(cornerRadius: 6))
Text("TV")
.font(MatchTypography.displaySmall)
.foregroundStyle(MatchColors.textSecondary)
}
if showSlogan {
Text("OGNI PARTITA, OGNI EVENTO, PER I TUOI TIFOSI.")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.padding(.top, 12)
}
}
}
}
@@ -0,0 +1,30 @@
import SwiftUI
struct MatchPrimaryButton: View {
let label: String
let action: () -> Void
var enabled: Bool = true
var loading: Bool = false
var body: some View {
Button(action: action) {
Group {
if loading {
ProgressView()
.tint(.white)
} else {
Text(label)
.font(MatchTypography.labelLarge)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
.frame(maxWidth: .infinity)
.frame(height: 52)
}
.buttonStyle(.plain)
.background(enabled && !loading ? MatchColors.primaryRed : MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(enabled && !loading ? .white : MatchColors.textSecondary)
.disabled(!enabled || loading)
}
}
@@ -0,0 +1,25 @@
import SwiftUI
struct MatchScreenScaffold<Content: View, TopBar: View>: View {
@ViewBuilder var topBar: () -> TopBar
@ViewBuilder var content: () -> Content
init(
@ViewBuilder topBar: @escaping () -> TopBar = { EmptyView() },
@ViewBuilder content: @escaping () -> Content
) {
self.topBar = topBar
self.content = content
}
var body: some View {
VStack(spacing: 0) {
topBar()
content()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(MatchColors.background)
.foregroundStyle(.white)
}
}
@@ -0,0 +1,25 @@
import SwiftUI
struct MatchSecondaryButton: View {
let label: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(action: action) {
Text(label)
.font(MatchTypography.labelLarge)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(maxWidth: .infinity)
.frame(height: 52)
}
.buttonStyle(.plain)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(enabled ? MatchColors.outline : MatchColors.surfaceElevated, lineWidth: 1)
)
.foregroundStyle(enabled ? .white : MatchColors.textSecondary)
.disabled(!enabled)
}
}
@@ -0,0 +1,16 @@
import SwiftUI
struct MatchStatusBadge: View {
let text: String
var backgroundColor: Color = MatchColors.surfaceElevated
var textColor: Color = MatchColors.accentYellow
var body: some View {
Text(text)
.font(MatchTypography.labelLarge)
.foregroundStyle(textColor)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 6))
}
}
@@ -0,0 +1,125 @@
import SwiftUI
struct LoginScreen: View {
@ObservedObject var container: AppContainer
let onLoggedIn: () -> Void
@State private var email = ""
@State private var password = ""
@State private var error: String?
@State private var loading = false
@State private var passwordVisible = false
var body: some View {
MatchScreenScaffold {
ScrollView {
VStack(spacing: 0) {
MatchLiveWordmark(showSlogan: true)
.padding(.top, 32)
Text("ACCEDI")
.font(MatchTypography.headlineMedium)
.padding(.top, 48)
Text("Gestisci le dirette della tua squadra")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
VStack(spacing: 16) {
MatchTextField(title: "Email", text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
MatchSecureField(title: "Password", text: $password, visible: $passwordVisible)
}
.padding(.top, 32)
if let error {
Text(error)
.foregroundStyle(MatchColors.primaryRed)
.multilineTextAlignment(.center)
.padding(.top, 12)
}
MatchPrimaryButton(
label: "ACCEDI",
action: submitLogin,
enabled: !email.isEmpty && !password.isEmpty,
loading: loading
)
.padding(.top, 32)
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
}
}
private func submitLogin() {
guard !loading, !email.isEmpty, !password.isEmpty else { return }
loading = true
error = nil
Task {
do {
_ = try await container.authRepository.login(email: email.trimmingCharacters(in: .whitespaces), password: password)
onLoggedIn()
} catch {
let message = error.localizedDescription
if message.contains("401") {
self.error = "Email o password non corretti"
} else if message.localizedCaseInsensitiveContains("timeout") {
self.error = "Server non raggiungibile. Verifica la connessione."
} else {
self.error = message
}
}
loading = false
}
}
}
private struct MatchTextField: View {
let title: String
@Binding var text: String
var placeholder: String = ""
var keyboard: UIKeyboardType = .default
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
TextField(placeholder, text: $text)
.textInputAutocapitalization(.never)
.keyboardType(keyboard)
.autocorrectionDisabled()
.padding(12)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
}
}
}
private struct MatchSecureField: View {
let title: String
@Binding var text: String
@Binding var visible: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
HStack {
Group {
if visible {
TextField("", text: $text)
} else {
SecureField("", text: $text)
}
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button(action: { visible.toggle() }) {
Image(systemName: visible ? "eye.slash" : "eye")
.foregroundStyle(MatchColors.textSecondary)
}
}
.padding(12)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
}
}
}
@@ -0,0 +1,533 @@
import SwiftUI
struct MatchesScreen: View {
@ObservedObject var container: AppContainer
var refreshToken: Int = 0
let onOpenSetup: (String) -> Void
let onOpenBroadcast: (String) -> Void
let onLogout: () -> Void
@State private var loading = true
@State private var refreshing = false
@State private var actionLoading = false
@State private var error: String?
@State private var teams: [Team] = []
@State private var activeTeam: Team?
@State private var matches: [Match] = []
@State private var showNewMatch = false
@State private var showSchedule = false
@State private var showTeamPicker = false
@State private var resumeMatch: Match?
@State private var deleteMatch: Match?
@State private var snackbar: String?
var body: some View {
MatchScreenScaffold(
topBar: {
HStack {
MatchLiveWordmark(compact: true)
Spacer()
Button("Esci") {
Task {
await container.authRepository.logout()
onLogout()
}
}
.foregroundStyle(MatchColors.textSecondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
},
content: {
Group {
if loading {
ProgressView().tint(MatchColors.primaryRed)
} else if teams.isEmpty {
VStack(spacing: 16) {
Text("Nessuna squadra disponibile")
.foregroundStyle(MatchColors.textSecondary)
MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) })
}
.padding(24)
} else if let error {
VStack(spacing: 16) {
Text(error).foregroundStyle(MatchColors.primaryRed).multilineTextAlignment(.center)
MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) })
}
.padding(24)
} else {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 16) {
Text("Ciao, \(container.tokenStore.session?.user.name ?? "")")
.font(MatchTypography.headlineMedium)
Text("Riprendi una diretta in corso o avvia una partita programmata.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 12) {
MatchSecondaryButton(label: "PARTITA PROGRAMMATA", action: { showSchedule = true })
MatchPrimaryButton(label: "NUOVA PARTITA", action: { showNewMatch = true })
}
if let activeTeam {
TeamPickerBar(
team: activeTeam,
showPicker: teams.count > 1,
onTap: { if teams.count > 1 { showTeamPicker = true } }
)
}
if let active = activeMatch {
ActiveSessionBanner(match: active) {
resumeMatch = active
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 8)
Text(calendarSectionTitle)
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.textSecondary)
.padding(.horizontal, 20)
.padding(.vertical, 8)
if calendarMatches.isEmpty && activeMatch == nil {
Text(emptyCalendarMessage)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 12)
} else if calendarMatches.isEmpty {
Text("Nessuna altra partita in calendario.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 12)
} else {
ForEach(calendarMatches) { match in
MatchListCard(
match: match,
onTap: { openMatch(match) },
onDelete: (!match.hasActiveSession && !match.streamCompleted)
? { deleteMatch = match }
: nil
)
}
}
}
.padding(.bottom, 24)
}
.refreshable { reload(showSpinner: false) }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
)
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
.alert("Riprendi diretta?", isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
Button("Riprendi") {
if let match = resumeMatch { resumeBroadcast(match) }
resumeMatch = nil
}
Button("Configura", role: .cancel) {
if let match = resumeMatch { onOpenSetup(match.id) }
resumeMatch = nil
}
}
.alert("Elimina partita?", isPresented: Binding(get: { deleteMatch != nil }, set: { if !$0 { deleteMatch = nil } })) {
Button("Elimina", role: .destructive) {
if let match = deleteMatch {
Task {
try? await container.matchRepository.deleteMatch(matchId: match.id)
reload(showSpinner: false)
}
}
deleteMatch = nil
}
Button("Annulla", role: .cancel) { deleteMatch = nil }
}
.sheet(isPresented: $showNewMatch) {
NewMatchSheet(
onSchedule: {
showNewMatch = false
showSchedule = true
},
onQuickStart: {
showNewMatch = false
createQuickMatch()
}
)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showSchedule) {
ScheduleMatchSheet(container: container, teamId: activeTeam?.id) { match in
showSchedule = false
onOpenSetup(match.id)
}
}
.sheet(isPresented: $showTeamPicker) {
TeamPickerSheet(teams: teams, activeTeamId: activeTeam?.id) { team in
container.matchRepository.selectTeam(teamId: team.id)
activeTeam = team
reload(showSpinner: false)
showTeamPicker = false
}
}
.overlay {
if actionLoading {
Color.black.opacity(0.35)
.ignoresSafeArea()
ProgressView().tint(MatchColors.primaryRed)
}
}
.overlay(alignment: .bottom) {
if let snackbar {
Text(snackbar)
.padding()
.background(MatchColors.surfaceElevated)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.snackbar = nil }
}
}
}
}
private var activeMatch: Match? {
matches.first { $0.canResumeCamera || ($0.hasActiveSession && $0.activeSessionStatus == "idle") }
}
private var scheduledMatches: [Match] {
matches.filter { !$0.hasActiveSession && MatchHubFilter.isScheduledFuture($0) }
}
private var calendarMatches: [Match] {
let drafts = matches.filter { MatchHubFilter.isDraft($0) }
return (scheduledMatches + drafts).uniqued(by: \.id)
}
private var calendarSectionTitle: String {
if calendarMatches.isEmpty && activeMatch == nil {
return "Nessuna partita in calendario"
}
if !scheduledMatches.isEmpty {
return "Partite programmate"
}
return "Pronte da avviare"
}
private var emptyCalendarMessage: String {
var message = "Programma una partita o avviane una nuova con «Nuova partita»."
if let teamName = activeTeam?.name {
message += "\n\nSquadra attiva: \(teamName)."
}
if teams.count > 1 {
message += "\nHai più squadre: verifica quella selezionata sopra."
}
return message
}
private func reload(showSpinner: Bool) {
if showSpinner { loading = true } else { refreshing = true }
error = nil
Task {
do {
let loadedTeams = try await container.matchRepository.fetchTeams()
teams = loadedTeams
activeTeam = container.matchRepository.resolveActiveTeam(teams: loadedTeams)
if let team = activeTeam {
matches = try await container.matchRepository.fetchMatchesForTeam(teamId: team.id)
}
} catch {
self.error = UserFacingError.message(for: error)
}
loading = false
refreshing = false
}
}
private func openMatch(_ match: Match) {
if match.hasActiveSession { resumeMatch = match } else { onOpenSetup(match.id) }
}
private func resumeBroadcast(_ match: Match) {
guard !actionLoading else { return }
actionLoading = true
Task {
do {
let sessionId = try await MatchSessionLauncher.resumeBroadcastSession(match: match, sessionRepository: container.sessionRepository)
onOpenBroadcast(sessionId)
} catch {
snackbar = UserFacingError.message(for: error)
}
actionLoading = false
}
}
private func createQuickMatch() {
guard let teamId = activeTeam?.id, !actionLoading else { return }
actionLoading = true
Task {
do {
let match = try await container.matchRepository.createQuickMatch(teamId: teamId)
reload(showSpinner: false)
onOpenSetup(match.id)
} catch {
snackbar = UserFacingError.message(for: error)
}
actionLoading = false
}
}
}
private struct TeamPickerBar: View {
let team: Team
var showPicker: Bool = true
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
Text(team.name).font(MatchTypography.titleMedium)
Spacer()
MatchStatusBadge(text: team.sportKey.uppercased())
if showPicker {
Image(systemName: "chevron.down")
.foregroundStyle(MatchColors.textSecondary)
}
}
.padding()
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.disabled(!showPicker)
}
}
private struct ActiveSessionBanner: View {
let match: Match
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Image(systemName: "video.fill")
.foregroundStyle(MatchColors.primaryRed)
VStack(alignment: .leading, spacing: 4) {
Text("Riprendi diretta in corso")
.font(MatchTypography.titleMedium)
.foregroundStyle(MatchColors.primaryRed)
Text("\(match.teamName) vs \(match.opponentName)")
.font(MatchTypography.bodyMedium)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(MatchColors.primaryRed)
}
.padding(14)
.background(MatchColors.primaryRed.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
private struct MatchListCard: View {
let match: Match
let onTap: () -> Void
let onDelete: (() -> Void)?
var body: some View {
Button(action: onTap) {
HStack(alignment: .center, spacing: 8) {
VStack(alignment: .leading, spacing: 4) {
Text("\(match.teamName) vs \(match.opponentName)")
.font(MatchTypography.titleMedium)
if let date = ApiInstant.formatMatchDate(match.scheduledAt) {
Text(date)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
}
if let location = match.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty {
Text(location)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
}
}
Spacer(minLength: 8)
Text(MatchPresentation.statusLabel(for: match))
.font(MatchTypography.labelLarge)
.foregroundStyle(match.hasActiveSession ? MatchColors.primaryRed : MatchColors.textSecondary)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
match.hasActiveSession
? MatchColors.primaryRed.opacity(0.2)
: MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 8)
)
if let onDelete {
Button(action: onDelete) {
Image(systemName: "trash")
.foregroundStyle(MatchColors.textSecondary)
}
.buttonStyle(.plain)
}
}
.padding(16)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.padding(.horizontal, 20)
.padding(.vertical, 6)
}
}
private struct NewMatchSheet: View {
let onSchedule: () -> Void
let onQuickStart: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Nuova partita")
.font(MatchTypography.headlineMedium)
Text("Programma in anticipo o avvia la configurazione diretta subito.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 6)
VStack(spacing: 10) {
SheetOptionTile(
systemImage: "calendar.badge.clock",
title: "Programma partita",
subtitle: "Data, ora e avversario — visibile anche sul sito",
action: onSchedule
)
SheetOptionTile(
systemImage: "play.circle",
title: "Avvia subito",
subtitle: "Crea la partita e passa al wizard senza orario",
action: onQuickStart
)
}
.padding(.top, 20)
Spacer(minLength: 24)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(MatchColors.surface)
}
}
private struct SheetOptionTile: View {
let systemImage: String
let title: String
let subtitle: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
Image(systemName: systemImage)
.font(.title2)
.foregroundStyle(MatchColors.primaryRed)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(MatchTypography.titleMedium)
Text(subtitle)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(MatchColors.textSecondary)
}
.padding(16)
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
private struct ScheduleMatchSheet: View {
@ObservedObject var container: AppContainer
let teamId: String?
let onCreated: (Match) -> Void
@Environment(\.dismiss) private var dismiss
@State private var opponent = ""
@State private var location = ""
@State private var date = Date().addingTimeInterval(86400)
var body: some View {
NavigationStack {
Form {
TextField("Avversario", text: $opponent)
TextField("Luogo", text: $location)
DatePicker("Data", selection: $date)
}
.navigationTitle("Partita programmata")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Chiudi") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Crea") { create() }.disabled(opponent.isEmpty || teamId == nil)
}
}
}
}
private func create() {
guard let teamId else { return }
Task {
if let match = try? await container.matchRepository.createScheduledMatch(
teamId: teamId,
opponentName: opponent,
scheduledAt: date,
location: location.nilIfEmpty
) {
onCreated(match)
}
}
}
}
private struct TeamPickerSheet: View {
let teams: [Team]
let activeTeamId: String?
let onSelect: (Team) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(teams) { team in
Button {
onSelect(team)
dismiss()
} label: {
HStack {
Text(team.name)
Spacer()
if team.id == activeTeamId {
Image(systemName: "checkmark").foregroundStyle(MatchColors.primaryRed)
}
}
}
}
.navigationTitle("Squadra")
}
}
}
private extension Array {
func uniqued<ID: Hashable>(by keyPath: KeyPath<Element, ID>) -> [Element] {
var seen = Set<ID>()
return filter { seen.insert($0[keyPath: keyPath]).inserted }
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
@@ -0,0 +1,79 @@
import SwiftUI
struct AppNavHost: View {
@StateObject private var container = AppContainer()
@State private var path: [Routes] = []
@State private var wizardRoute: WizardRoute?
@State private var broadcastRoute: BroadcastRoute?
/// Incrementato al ritorno da wizard/broadcast per ricaricare l'hub partite.
@State private var matchesRefreshToken = 0
var body: some View {
NavigationStack(path: $path) {
SplashScreen(
container: container,
onAuthenticated: { path = [.matches] },
onUnauthenticated: { path = [.login] }
)
.navigationDestination(for: Routes.self) { route in
switch route {
case .splash:
EmptyView()
case .login:
LoginScreen(container: container) { path = [.matches] }
case .matches:
MatchesScreen(
container: container,
refreshToken: matchesRefreshToken,
onOpenSetup: { matchId in
wizardRoute = WizardRoute(matchId: matchId, step: 1)
},
onOpenBroadcast: { sessionId in
broadcastRoute = BroadcastRoute(sessionId: sessionId)
},
onLogout: { path = [.login] }
)
.lockPortraitOrientation()
case .setup, .broadcast:
EmptyView()
}
}
}
.lockPortraitOrientation()
.fullScreenCover(item: $wizardRoute) { route in
WizardShellScreen(
container: container,
matchId: route.matchId,
step: route.step,
onClose: {
container.wizardSession.reset()
wizardRoute = nil
matchesRefreshToken += 1
},
onStartLive: { sessionId in
wizardRoute = nil
broadcastRoute = BroadcastRoute(sessionId: sessionId)
}
)
.lockPortraitOrientation()
}
.fullScreenCover(item: $broadcastRoute, onDismiss: {
AppOrientation.lockPortrait()
matchesRefreshToken += 1
}) { route in
BroadcastScreen(container: container, sessionId: route.sessionId) {
AppOrientation.lockPortrait()
container.wizardSession.reset()
broadcastRoute = nil
path = [.matches]
}
.keepScreenOn()
}
.onChange(of: broadcastRoute?.sessionId) { sessionId in
if sessionId == nil {
AppOrientation.lockPortrait()
}
}
.environmentObject(container)
}
}
@@ -0,0 +1,14 @@
import Foundation
struct WizardRoute: Identifiable, Equatable {
let matchId: String
let step: Int
var id: String { "\(matchId)-\(step)" }
}
struct BroadcastRoute: Identifiable, Equatable {
let sessionId: String
var id: String { sessionId }
}
@@ -0,0 +1,9 @@
import Foundation
enum Routes: Hashable {
case splash
case login
case matches
case setup(matchId: String, step: Int)
case broadcast(sessionId: String)
}
@@ -0,0 +1,24 @@
import AVFoundation
import SwiftUI
@MainActor
final class BroadcastPermissions: ObservableObject {
@Published var cameraGranted = false
@Published var microphoneGranted = false
var allGranted: Bool { cameraGranted && microphoneGranted }
func refresh() async {
cameraGranted = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
microphoneGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
}
func requestAll() async {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .allowBluetooth])
let video = await AVCaptureDevice.requestAccess(for: .video)
cameraGranted = video
let audio = await AVCaptureDevice.requestAccess(for: .audio)
microphoneGranted = audio
}
}
@@ -0,0 +1,27 @@
import SwiftUI
struct SplashScreen: View {
@ObservedObject var container: AppContainer
let onAuthenticated: () -> Void
let onUnauthenticated: () -> Void
var body: some View {
MatchScreenScaffold {
ZStack {
MatchLiveWordmark(showSlogan: true)
ProgressView()
.tint(MatchColors.primaryRed)
.frame(maxHeight: .infinity, alignment: .bottom)
.padding(.bottom, 48)
}
}
.task {
container.bootstrapAuth()
if await container.authRepository.validateOrRefresh() != nil {
onAuthenticated()
} else {
onUnauthenticated()
}
}
}
}
@@ -0,0 +1,15 @@
import SwiftUI
import UIKit
struct KeepScreenOnModifier: ViewModifier {
func body(content: Content) -> some View {
content.onAppear { UIApplication.shared.isIdleTimerDisabled = true }
.onDisappear { UIApplication.shared.isIdleTimerDisabled = false }
}
}
extension View {
func keepScreenOn() -> some View {
modifier(KeepScreenOnModifier())
}
}
@@ -0,0 +1,114 @@
import SwiftUI
import UIKit
enum AppOrientation {
enum Mode {
case portrait
case landscape
}
private(set) static var mode: Mode = .portrait
static func lockPortrait() {
apply(.portrait)
}
static func lockLandscape() {
apply(.landscape)
}
/// Torna al portrait (alias di `lockPortrait` per compatibilità).
static func unlock() {
lockPortrait()
}
static func apply(_ newMode: Mode) {
mode = newMode
refreshSupportedOrientations()
requestOrientationUpdate(for: newMode)
}
static var interfaceOrientationMask: UIInterfaceOrientationMask {
mode == .landscape ? .landscape : .portrait
}
private static func activeWindowScene() -> UIWindowScene? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
}
private static func refreshSupportedOrientations() {
guard let scene = activeWindowScene() else { return }
for window in scene.windows {
window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
topViewController(from: window.rootViewController)?.setNeedsUpdateOfSupportedInterfaceOrientations()
}
}
private static func requestOrientationUpdate(for mode: Mode) {
guard let scene = activeWindowScene() else { return }
let mask: UIInterfaceOrientationMask = mode == .landscape ? .landscape : .portrait
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
if mode == .portrait, scene.interfaceOrientation.isLandscape {
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
} else if mode == .landscape, scene.interfaceOrientation.isPortrait {
UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
}
}
private static func topViewController(from root: UIViewController?) -> UIViewController? {
if let presented = root?.presentedViewController {
return topViewController(from: presented)
}
if let nav = root as? UINavigationController {
return topViewController(from: nav.visibleViewController)
}
if let tab = root as? UITabBarController {
return topViewController(from: tab.selectedViewController)
}
return root
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
AppOrientation.lockPortrait()
return true
}
func application(
_ application: UIApplication,
supportedInterfaceOrientationsFor window: UIWindow?
) -> UIInterfaceOrientationMask {
AppOrientation.interfaceOrientationMask
}
}
private struct PortraitOrientationModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onAppear { AppOrientation.lockPortrait() }
}
}
private struct LandscapeOrientationModifier: ViewModifier {
func body(content: Content) -> some View {
content
.onAppear { AppOrientation.lockLandscape() }
.onDisappear { AppOrientation.lockPortrait() }
}
}
extension View {
func lockPortraitOrientation() -> some View {
modifier(PortraitOrientationModifier())
}
func lockLandscapeOrientation() -> some View {
modifier(LandscapeOrientationModifier())
}
}
@@ -0,0 +1,30 @@
import SwiftUI
enum MatchColors {
static let primaryRed = Color(red: 1, green: 0.176, blue: 0.176)
static let background = Color(red: 0.039, green: 0.039, blue: 0.039)
static let surface = Color(red: 0.118, green: 0.118, blue: 0.118)
static let surfaceElevated = Color(red: 0.165, green: 0.165, blue: 0.165)
static let accentYellow = Color(red: 0.961, green: 0.773, blue: 0.094)
static let successGreen = Color(red: 0.133, green: 0.773, blue: 0.369)
static let textSecondary = Color(red: 0.612, green: 0.639, blue: 0.686)
static let outline = Color(red: 0.251, green: 0.251, blue: 0.251)
}
struct MatchTypography {
static let displaySmall = Font.system(size: 28, weight: .black).leading(.loose)
static let headlineMedium = Font.system(size: 24, weight: .bold)
static let titleMedium = Font.system(size: 18, weight: .semibold)
static let bodyMedium = Font.system(size: 14, weight: .regular)
static let labelLarge = Font.system(size: 14, weight: .heavy).leading(.loose)
}
struct MatchLiveTheme<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
content()
.preferredColorScheme(.dark)
.tint(MatchColors.primaryRed)
}
}
@@ -0,0 +1,438 @@
import SwiftUI
import UIKit
private enum WizardDefaults {
static let setsToWin = 3
static let pointsPerSet = 25
static let pointsDecidingSet = 15
static let minPointLead = 2
static let periods = 4
static let periodDurationSecs = 600
static let overtimeDurationSecs = 300
static let opponentColor = "#1E3A8A"
}
private func overlayLabel(_ key: String) -> String {
let name = OverlayKind.fromApi(key).rawValue
return name.prefix(1).uppercased() + name.dropFirst()
}
private func matchHasCustomOverlay(_ match: Match, sportDefaultOverlay: String?) -> Bool {
guard let defaultOverlay = sportDefaultOverlay, let current = match.overlayKind else { return false }
return current != defaultOverlay
}
private func matchHasCustomScoring(_ match: Match, boardType: String) -> Bool {
guard let rules = match.scoringRules else { return false }
switch boardType {
case "basket", "timed":
return rules.periods != WizardDefaults.periods
|| rules.periodDurationSecs != WizardDefaults.periodDurationSecs
|| rules.overtimeDurationSecs != WizardDefaults.overtimeDurationSecs
default:
if match.setsToWin != WizardDefaults.setsToWin { return true }
return rules.pointsPerSet != WizardDefaults.pointsPerSet
|| rules.pointsDecidingSet != WizardDefaults.pointsDecidingSet
|| rules.minPointLead != WizardDefaults.minPointLead
}
}
struct StepMatchScreen: View {
@ObservedObject var container: AppContainer
let match: Match
let onNext: () -> Void
let onError: (String) -> Void
@State private var opponent: String
@State private var location: String
@State private var campionato: String
@State private var homeTeam: Team?
@State private var homeLogoUrl: String?
@State private var opponentLogoUrl: String?
@State private var homePrimaryColor: String
@State private var opponentPrimaryColor: String
@State private var homeLogoImage: UIImage?
@State private var opponentLogoImage: UIImage?
@State private var saving = false
@State private var sports: [SportOption] = []
@State private var customRules = false
@State private var setsToWin: Int
@State private var pointsPerSet: Int
@State private var pointsDecidingSet: Int
@State private var pointsPerSetText: String
@State private var pointsDecidingSetText: String
@State private var periods: Int
@State private var periodDurationMins: Int
@State private var overtimeDurationMins: Int
@State private var periodDurationText: String
@State private var overtimeDurationText: String
@State private var customOverlay = false
@State private var selectedOverlay: String
private var isScheduledMatch: Bool { match.scheduledAt != nil }
init(container: AppContainer, match: Match, onNext: @escaping () -> Void, onError: @escaping (String) -> Void) {
self.container = container
self.match = match
self.onNext = onNext
self.onError = onError
let rules = match.scoringRules
_opponent = State(initialValue: match.opponentName)
_location = State(initialValue: match.location ?? "")
_campionato = State(initialValue: match.category ?? "")
_homeLogoUrl = State(initialValue: match.homeLogoUrl)
_opponentLogoUrl = State(initialValue: match.opponentLogoUrl)
_homePrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.homePrimaryColor))
_opponentPrimaryColor = State(initialValue: ColorHex.normalizeHexColor(match.opponentPrimaryColor, fallback: WizardDefaults.opponentColor))
_setsToWin = State(initialValue: min(max(match.setsToWin, 2), 3))
_pointsPerSet = State(initialValue: rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)
_pointsDecidingSet = State(initialValue: rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)
_pointsPerSetText = State(initialValue: "\(rules?.pointsPerSet ?? WizardDefaults.pointsPerSet)")
_pointsDecidingSetText = State(initialValue: "\(rules?.pointsDecidingSet ?? WizardDefaults.pointsDecidingSet)")
_periods = State(initialValue: rules?.periods ?? WizardDefaults.periods)
_periodDurationMins = State(initialValue: (rules?.periodDurationSecs ?? WizardDefaults.periodDurationSecs) / 60)
_overtimeDurationMins = State(initialValue: (rules?.overtimeDurationSecs ?? WizardDefaults.overtimeDurationSecs) / 60)
_periodDurationText = State(initialValue: "\((rules?.periodDurationSecs ?? WizardDefaults.periodDurationSecs) / 60)")
_overtimeDurationText = State(initialValue: "\((rules?.overtimeDurationSecs ?? WizardDefaults.overtimeDurationSecs) / 60)")
_selectedOverlay = State(initialValue: match.effectiveOverlayKind)
}
private var teamSportKey: String? {
homeTeam?.sportKey.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
}
private var effectiveSportKey: String {
teamSportKey ?? match.sportKey
}
private var currentSport: SportOption? {
sports.first { $0.key == effectiveSportKey }
}
private var allowedOverlays: [String] {
currentSport?.allowedOverlays ?? []
}
private var defaultOverlay: String {
currentSport?.overlay ?? match.effectiveOverlayKind
}
private var boardType: String {
currentSport?.board ?? homeTeam?.boardType ?? match.boardType
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Dettagli partita")
.font(MatchTypography.headlineMedium)
.padding(.bottom, 20)
TeamBrandingRow(
sectionLabel: "Squadra di casa",
teamName: .constant(match.teamName),
nameEditable: false,
remoteLogoUrl: homeLogoUrl,
localLogoImage: $homeLogoImage,
primaryColorHex: $homePrimaryColor,
placeholderColorSeed: "home-\(match.teamId)"
)
.padding(.bottom, 16)
TeamBrandingRow(
sectionLabel: "Squadra avversaria",
teamName: $opponent,
nameEditable: true,
remoteLogoUrl: opponentLogoUrl,
localLogoImage: $opponentLogoImage,
primaryColorHex: $opponentPrimaryColor,
placeholderColorSeed: "away-\(match.id)"
)
.padding(.bottom, 16)
WizardOutlinedField(label: "Luogo", text: $location)
.padding(.bottom, 12)
WizardOutlinedField(label: "Campionato (facoltativo)", text: $campionato)
Text("Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 4)
if isScheduledMatch {
WizardReadOnlyField(
label: "Programmata per",
value: ApiInstant.formatMatchDate(match.scheduledAt) ?? ""
)
.padding(.top, 16)
}
if !allowedOverlays.isEmpty {
Toggle("Overlay video personalizzato", isOn: $customOverlay)
.padding(.top, 20)
if customOverlay {
VStack(spacing: 6) {
ForEach(allowedOverlays, id: \.self) { overlayKey in
WizardChoiceButton(
label: overlayLabel(overlayKey),
selected: selectedOverlay == overlayKey,
action: { selectedOverlay = overlayKey }
)
}
}
.padding(.top, 12)
}
}
Toggle(isOn: $customRules) {
VStack(alignment: .leading, spacing: 4) {
Text("Regole punteggio personalizzate")
Text(customRulesDescription)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
}
}
.padding(.top, 20)
if customRules && ["basket", "timed"].contains(boardType) {
periodRulesSection
}
if customRules && ["volley", "racket"].contains(boardType) {
volleyRulesSection
}
MatchPrimaryButton(label: "AVANTI >", action: saveAndContinue, enabled: !saving, loading: saving)
.padding(.top, 32)
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.task(id: match.id) {
if let cached = container.wizardSession.team {
applyHomeTeam(cached)
} else if let team = await container.matchRepository.fetchTeam(teamId: match.teamId) {
container.wizardSession.team = team
applyHomeTeam(team)
}
if let loaded = try? await container.matchRepository.fetchSports() {
sports = loaded
}
customRules = matchHasCustomScoring(match, boardType: boardType)
customOverlay = matchHasCustomOverlay(match, sportDefaultOverlay: defaultOverlay)
if customOverlay {
selectedOverlay = match.overlayKind ?? match.effectiveOverlayKind
} else {
selectedOverlay = defaultOverlay
}
}
.onChange(of: customOverlay) { enabled in
if !enabled {
selectedOverlay = defaultOverlay
} else if !allowedOverlays.contains(selectedOverlay) {
selectedOverlay = defaultOverlay
}
}
.onChange(of: defaultOverlay) { overlay in
if !customOverlay {
selectedOverlay = overlay
}
}
}
private var customRulesDescription: String {
if !customRules && ["basket", "timed"].contains(boardType) {
return "Regole standard dello sport selezionato."
}
if customRules && ["basket", "timed"].contains(boardType) {
return "Torneo non standard: tempi e periodi personalizzati."
}
if customRules {
return "Torneo non standard: imposta set e punteggi."
}
return "Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
}
private var periodRulesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text(boardType == "basket" ? "Quarti" : "Tempi")
.font(MatchTypography.bodyMedium)
.padding(.top, 16)
HStack(spacing: 8) {
ForEach([2, 4], id: \.self) { value in
WizardChoiceButton(
label: "\(value)",
selected: periods == value,
action: { periods = value }
)
.frame(maxWidth: .infinity)
}
}
WizardOutlinedField(
label: boardType == "basket" ? "Minuti per quarto" : "Minuti per tempo",
text: $periodDurationText
)
.onChange(of: periodDurationText) { text in
let filtered = String(text.filter(\.isNumber).prefix(3))
if filtered != text { periodDurationText = filtered }
if let value = Int(filtered) { periodDurationMins = min(max(value, 1), 120) }
}
WizardOutlinedField(label: "Minuti supplementari", text: $overtimeDurationText)
.onChange(of: overtimeDurationText) { text in
let filtered = String(text.filter(\.isNumber).prefix(3))
if filtered != text { overtimeDurationText = filtered }
if let value = Int(filtered) { overtimeDurationMins = min(max(value, 1), 60) }
}
}
}
private var volleyRulesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Set da vincere la partita")
.font(MatchTypography.bodyMedium)
.padding(.top, 16)
HStack(spacing: 8) {
ForEach([2, 3], id: \.self) { value in
WizardChoiceButton(
label: "\(value)",
selected: setsToWin == value,
action: { setsToWin = value }
)
.frame(maxWidth: .infinity)
}
}
WizardOutlinedField(label: "Punti per vincere un set", text: $pointsPerSetText)
.onChange(of: pointsPerSetText) { text in
let filtered = String(text.filter(\.isNumber).prefix(2))
if filtered != text { pointsPerSetText = filtered }
if let value = Int(filtered) { pointsPerSet = min(max(value, 1), 99) }
}
WizardOutlinedField(label: "Punti tie-break (ultimo set)", text: $pointsDecidingSetText)
.onChange(of: pointsDecidingSetText) { text in
let filtered = String(text.filter(\.isNumber).prefix(2))
if filtered != text { pointsDecidingSetText = filtered }
if let value = Int(filtered) { pointsDecidingSet = min(max(value, 1), 99) }
}
}
}
private func saveAndContinue() {
let trimmedOpponent = opponent.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedOpponent.isEmpty else {
onError("Inserisci il nome avversario")
return
}
if customRules && ["volley", "racket"].contains(boardType) {
guard let perSet = Int(pointsPerSetText), perSet >= 1 else {
onError("Inserisci i punti per vincere un set")
return
}
guard let deciding = Int(pointsDecidingSetText), deciding >= 1 else {
onError("Inserisci i punti del tie-break")
return
}
pointsPerSet = perSet
pointsDecidingSet = deciding
}
if customRules && ["basket", "timed"].contains(boardType) {
guard let periodMins = Int(periodDurationText), periodMins >= 1 else {
onError("Inserisci la durata del periodo in minuti")
return
}
guard let overtimeMins = Int(overtimeDurationText), overtimeMins >= 1 else {
onError("Inserisci la durata dei supplementari in minuti")
return
}
periodDurationMins = periodMins
overtimeDurationMins = overtimeMins
}
let resolvedSets: Int
if customRules && ["volley", "racket"].contains(boardType) {
resolvedSets = setsToWin
} else {
resolvedSets = WizardDefaults.setsToWin
}
let resolvedRules: ScoringRules?
if customRules && ["volley", "racket"].contains(boardType) {
resolvedRules = ScoringRules(
pointsPerSet: pointsPerSet,
pointsDecidingSet: pointsDecidingSet,
minPointLead: match.scoringRules?.minPointLead ?? WizardDefaults.minPointLead,
periods: WizardDefaults.periods,
periodDurationSecs: WizardDefaults.periodDurationSecs,
overtimeDurationSecs: WizardDefaults.overtimeDurationSecs
)
} else if customRules && ["basket", "timed"].contains(boardType) {
resolvedRules = ScoringRules(
pointsPerSet: WizardDefaults.pointsPerSet,
pointsDecidingSet: WizardDefaults.pointsDecidingSet,
minPointLead: WizardDefaults.minPointLead,
periods: periods,
periodDurationSecs: periodDurationMins * 60,
overtimeDurationSecs: overtimeDurationMins * 60
)
} else {
resolvedRules = nil
}
let initialHomeColor = ColorHex.normalizeHexColor(homeTeam?.primaryColor ?? match.homePrimaryColor)
let homeBrandingChanged =
ColorHex.normalizeHexColor(homePrimaryColor) != initialHomeColor || homeLogoImage != nil
let overlayKind: String? = {
if customOverlay && selectedOverlay != defaultOverlay { return selectedOverlay }
return ""
}()
guard !saving else { return }
saving = true
Task { @MainActor in
defer { saving = false }
do {
if homeBrandingChanged, let team = homeTeam {
_ = try await container.matchRepository.updateTeamBranding(
teamId: team.id,
primaryColor: ColorHex.normalizeHexColor(homePrimaryColor),
secondaryColor: team.secondaryColor,
logoImage: homeLogoImage
)
}
let updated = try await container.matchRepository.updateMatch(
matchId: match.id,
opponentName: trimmedOpponent,
location: location.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
scheduledAt: match.scheduledAt,
setsToWin: resolvedSets,
category: campionato.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
opponentPrimaryColor: opponentPrimaryColor,
scoringRules: resolvedRules,
sportKey: effectiveSportKey,
overlayKind: overlayKind,
opponentLogoImage: opponentLogoImage
)
container.wizardSession.match = updated
onNext()
} catch {
if let message = UserFacingError.message(for: error) {
onError(message)
}
}
}
}
private func applyHomeTeam(_ team: Team) {
homeTeam = team
homePrimaryColor = ColorHex.normalizeHexColor(team.primaryColor ?? match.homePrimaryColor)
homeLogoUrl = team.logoUrl ?? match.homeLogoUrl
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
@@ -0,0 +1,249 @@
import SwiftUI
import UIKit
struct StepNetworkTestScreen: View {
@ObservedObject var container: AppContainer
let match: Match
let session: StreamSession
let onBack: () -> Void
let onStartLive: (String) -> Void
let onError: (String) -> Void
@State private var testing = false
@State private var testCompleted = false
@State private var ready = false
@State private var downloadMbps = 0.0
@State private var uploadMbps = 0.0
@State private var latencyMs = 0
@State private var networkType = ""
@State private var selectedQualityLabel: String?
@State private var starting = false
@State private var currentSession: StreamSession
init(
container: AppContainer,
match: Match,
session: StreamSession,
onBack: @escaping () -> Void,
onStartLive: @escaping (String) -> Void,
onError: @escaping (String) -> Void
) {
self.container = container
self.match = match
self.session = session
self.onBack = onBack
self.onStartLive = onStartLive
self.onError = onError
_currentSession = State(initialValue: session)
}
private var shareUrl: String? {
currentSession.watchShareUrl()
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Test rete")
.font(MatchTypography.headlineMedium)
Text("Verifica che la connessione regga l'upload della diretta.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 8)
HStack(spacing: 8) {
MetricCard(
label: "Download",
value: testing ? "..." : String(format: "%.1f Mbps", downloadMbps)
)
MetricCard(
label: "Upload",
value: testing ? "..." : String(format: "%.1f Mbps", uploadMbps),
highlight: ready
)
MetricCard(
label: "Latenza",
value: testing ? "..." : "\(latencyMs) ms"
)
}
.padding(.top, 24)
MetricCard(label: "Tipo rete", value: networkType)
.padding(.top, 12)
if testCompleted && ready {
Text("PRONTO PER ANDARE IN DIRETTA")
.font(MatchTypography.titleMedium)
.foregroundStyle(MatchColors.successGreen)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.padding(.top, 20)
if let quality = selectedQualityLabel {
WizardReadOnlyField(
label: "Qualità streaming (automatica)",
value: quality
)
.padding(.top, 12)
}
}
if testCompleted, let shareUrl {
WizardReadOnlyField(
label: currentSession.platform == "youtube" ? "Link YouTube" : "Link diretta",
value: shareUrl
)
.padding(.top, 16)
HStack(spacing: 8) {
MatchSecondaryButton(label: "COPIA", action: { copyToClipboard(shareUrl) })
if let url = URL(string: shareUrl) {
ShareLink(
item: url,
subject: Text("Diretta — \(match.teamName) vs \(match.opponentName)"),
message: Text(shareUrl)
) {
Text("CONDIVIDI")
.font(MatchTypography.labelLarge)
.frame(maxWidth: .infinity)
.frame(height: 52)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
}
.buttonStyle(.plain)
}
}
.padding(.top, 8)
MatchSecondaryButton(label: "CONDIVIDI LINK REGIA", action: shareRegiaLink)
.padding(.top, 8)
}
if !testCompleted {
MatchSecondaryButton(
label: testing ? "TEST IN CORSO..." : "AVVIA TEST RETE",
action: runTest,
enabled: !testing
)
.padding(.top, 24)
}
GeometryReader { proxy in
let spacing: CGFloat = 12
let backWidth = (proxy.size.width - spacing) / 3
let forwardWidth = (proxy.size.width - spacing) * 2 / 3
HStack(spacing: spacing) {
MatchSecondaryButton(label: "Indietro", action: onBack, enabled: !starting)
.frame(width: backWidth)
MatchPrimaryButton(
label: "INIZIA >",
action: startLive,
enabled: ready,
loading: starting
)
.frame(width: forwardWidth)
}
}
.frame(height: 52)
.padding(.top, 32)
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.task(id: session.id) {
await pollYoutubeIfNeeded()
}
}
private func runTest() {
testing = true
ready = false
Task { @MainActor in
networkType = DeviceTelemetry.snapshot().networkType
try? await Task.sleep(nanoseconds: 2_000_000_000)
downloadMbps = Double.random(in: 8...20)
uploadMbps = Double.random(in: 2...6)
latencyMs = Int.random(in: 20...99)
let result = try? await container.sessionRepository.submitNetworkTest(
sessionId: currentSession.id,
downloadMbps: downloadMbps,
uploadMbps: uploadMbps,
latencyMs: latencyMs,
networkType: networkType
)
if let result {
selectedQualityLabel = formatQualityLabel(result)
if let updated = try? await container.sessionRepository.fetchSession(id: currentSession.id) {
currentSession = updated
container.wizardSession.session = updated
}
}
ready = result?.ready ?? (uploadMbps >= 2.0)
testCompleted = true
testing = false
}
}
private func startLive() {
guard ready, !starting else { return }
starting = true
Task { @MainActor in
defer { starting = false }
do {
let started = try await container.sessionRepository.startSession(id: currentSession.id)
currentSession = started
container.wizardSession.session = started
onStartLive(started.id)
} catch {
if let message = UserFacingError.message(for: error) {
onError(message)
}
}
}
}
private func pollYoutubeIfNeeded() async {
guard currentSession.platform == "youtube", !currentSession.youtubeReady else { return }
for _ in 0..<20 {
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard let updated = try? await container.sessionRepository.fetchSession(id: currentSession.id) else { continue }
currentSession = updated
container.wizardSession.session = updated
if updated.youtubeReady || !(updated.youtubeWatchUrl?.isEmpty ?? true) { return }
}
}
private func copyToClipboard(_ text: String) {
UIPasteboard.general.string = text
}
private func shareRegiaLink() {
Task { @MainActor in
do {
let url = try await container.sessionRepository.createRegiaLink(sessionId: currentSession.id)
guard let link = URL(string: url) else { return }
presentShare(items: [link])
} catch {
if let message = UserFacingError.message(for: error) {
onError(message)
}
}
}
}
private func presentShare(items: [Any]) {
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else { return }
let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
root.present(controller, animated: true)
}
private func formatQualityLabel(_ result: NetworkTestResponse) -> String {
let bitrateMbps = Double(result.targetBitrate ?? 2_500_000) / 1_000_000.0
let fps = result.targetFps ?? 30
let resolution = result.qualityPreset?.hasPrefix("1080p") == true ? "1080p" : "720p"
return "\(resolution) · \(fps)fps · \(String(format: "%.1f", bitrateMbps)) Mbps"
}
}
@@ -0,0 +1,147 @@
import SwiftUI
struct StepTransmissionScreen: View {
@ObservedObject var container: AppContainer
let match: Match
let onBack: () -> Void
let onNext: () -> Void
let onError: (String) -> Void
@State private var team: Team?
@State private var platform = "matchlivetv"
@State private var privacy = "public"
@State private var creating = false
private var youtubeReady: Bool {
team?.isYoutubeReady == true
}
private var youtubeSubtitle: String {
guard let team else { return "Canale in attivazione" }
if !team.canUseYoutube { return "Premium Light o Full" }
if !team.isYoutubeReady { return "Canale in attivazione" }
return team.youtubeDestinationLabel
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if let plan = team?.planName {
Text("Piano \(plan)")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.bottom, 12)
}
Text("Piattaforma")
.font(MatchTypography.headlineMedium)
WizardPlatformCard(
title: "Match Live TV",
subtitle: "Diretta sul nostro sito (incluso)",
selected: platform == "matchlivetv",
onClick: { platform = "matchlivetv" }
)
.padding(.top, 12)
WizardPlatformCard(
title: "YouTube Live",
subtitle: youtubeSubtitle,
selected: platform == "youtube",
enabled: youtubeReady,
badge: team?.canUseYoutube == false ? "Premium" : nil,
onClick: selectYoutube
)
.padding(.top, 8)
Text("Visibilità")
.font(MatchTypography.headlineMedium)
.padding(.top, 24)
HStack(spacing: 8) {
WizardChoiceButton(
label: "PUBBLICO",
selected: privacy == "public",
action: { privacy = "public" }
)
.frame(maxWidth: .infinity)
WizardChoiceButton(
label: "NON IN ELENCO",
selected: privacy == "unlisted",
action: { privacy = "unlisted" }
)
.frame(maxWidth: .infinity)
}
.padding(.top, 12)
Text(visibilityDescription)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 8)
Text("La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 6)
WizardFooterButtons(
onBack: onBack,
forwardLabel: "AVANTI >",
forwardLoading: creating,
onForward: createSessionAndContinue
)
.padding(.top, 32)
}
.padding(.horizontal, 20)
.padding(.top, 12)
.padding(.bottom, 32)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.task {
if let cached = container.wizardSession.team {
team = cached
} else {
team = await container.matchRepository.fetchTeam(teamId: match.teamId)
container.wizardSession.team = team
}
}
}
private var visibilityDescription: String {
if privacy == "public" {
return "Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato)."
}
return "Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare."
}
private func selectYoutube() {
if youtubeReady {
platform = "youtube"
} else {
onError("YouTube non disponibile per questa squadra")
}
}
private func createSessionAndContinue() {
creating = true
Task {
do {
let session = try await container.sessionRepository.createSession(
matchId: match.id,
platform: platform,
privacyStatus: privacy,
youtubeChannel: platform == "youtube" ? team?.effectiveYoutubeChannel : nil
)
container.wizardSession.session = session
onNext()
} catch {
if let message = UserFacingError.message(for: error) {
onError(message)
}
}
creating = false
}
}
}
@@ -0,0 +1,224 @@
import PhotosUI
import SwiftUI
struct TeamBrandingRow: View {
let sectionLabel: String
@Binding var teamName: String
let nameEditable: Bool
let remoteLogoUrl: String?
@Binding var localLogoImage: UIImage?
@Binding var primaryColorHex: String
let placeholderColorSeed: String
@State private var showCustomize = false
private var hasLogo: Bool {
localLogoImage != nil || !(remoteLogoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
private var isConfigured: Bool {
hasLogo && !primaryColorHex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var displayColorHex: String {
let trimmed = primaryColorHex.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? ColorHex.placeholderTeamColor(seed: placeholderColorSeed) : trimmed
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(sectionLabel)
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.textSecondary)
HStack(spacing: 10) {
if isConfigured {
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 44)
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 36)
Text(teamName.isEmpty ? "Squadra" : teamName)
.font(MatchTypography.titleMedium)
.lineLimit(1)
} else {
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 32)
if nameEditable {
TextField("Nome avversario", text: $teamName)
.font(MatchTypography.titleMedium)
.padding(8)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
} else {
Text(teamName.isEmpty ? "Squadra" : teamName)
.font(MatchTypography.titleMedium)
.lineLimit(1)
}
}
Spacer(minLength: 0)
Button { showCustomize = true } label: {
Image(systemName: "ellipsis")
.foregroundStyle(MatchColors.textSecondary)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 12))
}
.sheet(isPresented: $showCustomize) {
TeamBrandingCustomizeSheet(
title: sectionLabel,
teamName: $teamName,
nameEditable: nameEditable,
remoteLogoUrl: remoteLogoUrl,
localLogoImage: $localLogoImage,
primaryColorHex: $primaryColorHex,
fallbackColorHex: ColorHex.placeholderTeamColor(seed: placeholderColorSeed),
onDismiss: { showCustomize = false }
)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
}
private struct TeamBrandingCustomizeSheet: View {
let title: String
@Binding var teamName: String
let nameEditable: Bool
let remoteLogoUrl: String?
@Binding var localLogoImage: UIImage?
@Binding var primaryColorHex: String
let fallbackColorHex: String
let onDismiss: () -> Void
@State private var draftName: String = ""
@State private var draftColor: String = ""
@State private var logoPickerItem: PhotosPickerItem?
private var hasLogo: Bool {
localLogoImage != nil || !(remoteLogoUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text("Personalizza").font(MatchTypography.headlineMedium)
Text(title)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 4)
if nameEditable {
Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20)
TextField("Nome avversario", text: $draftName)
.padding(12)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
.padding(.top, 8)
} else {
Text("Nome squadra").font(MatchTypography.labelLarge).padding(.top, 20)
Text(teamName).font(MatchTypography.titleMedium).padding(.top, 4)
}
Text("Logo").font(MatchTypography.labelLarge).padding(.top, 20)
HStack(spacing: 14) {
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 72)
VStack(alignment: .leading, spacing: 8) {
PhotosPicker(selection: $logoPickerItem, matching: .images) {
Text("CARICA LOGO")
.font(MatchTypography.labelLarge)
.frame(maxWidth: .infinity)
.frame(height: 52)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
}
.buttonStyle(.plain)
if hasLogo {
MatchSecondaryButton(label: "RIMUOVI", action: {
localLogoImage = nil
logoPickerItem = nil
})
}
}
}
.padding(.top, 10)
Text("Colore squadra").font(MatchTypography.labelLarge).padding(.top, 20)
TeamColorPickerPanel(initialColorHex: draftColor.isEmpty ? fallbackColorHex : draftColor) { draftColor = $0 }
.padding(.top, 12)
MatchPrimaryButton(label: "SALVA", action: save)
.padding(.top, 24)
MatchSecondaryButton(label: "ANNULLA", action: onDismiss)
.padding(.top, 8)
}
.padding(.horizontal, 20)
.padding(.bottom, 32)
}
.background(MatchColors.surfaceElevated)
.onAppear {
draftName = teamName
draftColor = primaryColorHex.isEmpty ? fallbackColorHex : primaryColorHex
}
.onChange(of: logoPickerItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let image = UIImage(data: data) {
localLogoImage = image
}
}
}
}
private func save() {
if nameEditable { teamName = draftName.trimmingCharacters(in: .whitespacesAndNewlines) }
primaryColorHex = ColorHex.normalizeHexColor(draftColor, fallback: fallbackColorHex)
onDismiss()
}
}
private struct TeamLogoImage: View {
let remoteLogoUrl: String?
let localLogoImage: UIImage?
let size: CGFloat
var body: some View {
Group {
if let localLogoImage {
Image(uiImage: localLogoImage)
.resizable()
.scaledToFill()
} else if let urlString = MediaUrl.resolve(remoteLogoUrl), let url = URL(string: urlString) {
AsyncImage(url: url) { phase in
if let image = phase.image {
image.resizable().scaledToFill()
} else {
placeholder
}
}
} else {
placeholder
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size > 50 ? 12 : 8))
.overlay(RoundedRectangle(cornerRadius: size > 50 ? 12 : 8).stroke(MatchColors.outline, lineWidth: 1))
}
private var placeholder: some View {
ZStack {
MatchColors.surface
Text("Nessun logo")
.font(.caption)
.foregroundStyle(MatchColors.textSecondary)
}
}
}
private struct ColorAccentBar: View {
let color: Color
let height: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: 2)
.fill(color)
.frame(width: 5, height: height)
}
}
@@ -0,0 +1,91 @@
import SwiftUI
struct TeamColorPickerPanel: View {
let initialColorHex: String
let onColorChange: (String) -> Void
@State private var hue: Double = 0
@State private var saturation: Double = 1
@State private var brightness: Double = 1
var body: some View {
let selected = ColorHex.swiftUIColor(
ColorHex.hsvToHex(hue: hue, saturation: saturation, brightness: brightness)
)
VStack(spacing: 14) {
Circle()
.fill(selected)
.frame(width: 56, height: 56)
.overlay(Circle().stroke(MatchColors.outline, lineWidth: 2))
saturationBrightnessPicker
VStack(alignment: .leading, spacing: 4) {
Text("Tonalità")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
hueGradient
Slider(value: $hue, in: 0...360) { _ in emit() }
.tint(.white)
}
}
.onAppear {
let hsv = ColorHex.hsvComponents(from: initialColorHex)
hue = hsv.hue
saturation = hsv.saturation
brightness = hsv.brightness
}
.onChange(of: hue) { _ in emit() }
.onChange(of: saturation) { _ in emit() }
.onChange(of: brightness) { _ in emit() }
}
private var hueGradient: some View {
LinearGradient(
colors: [.red, .yellow, .green, .cyan, .blue, .purple, .red],
startPoint: .leading,
endPoint: .trailing
)
.frame(height: 16)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
private var saturationBrightnessPicker: some View {
let baseHue = Color(
uiColor: UIColor(
hue: CGFloat(hue / 360),
saturation: 1,
brightness: 1,
alpha: 1
)
)
return GeometryReader { geo in
ZStack {
Rectangle().fill(.white)
Rectangle().fill(LinearGradient(colors: [.white, baseHue], startPoint: .leading, endPoint: .trailing))
Rectangle().fill(LinearGradient(colors: [.clear, .black], startPoint: .top, endPoint: .bottom))
Circle()
.strokeBorder(.white, lineWidth: 3)
.background(Circle().stroke(Color.black.opacity(0.35), lineWidth: 1.5))
.frame(width: 20, height: 20)
.position(
x: saturation * geo.size.width,
y: (1 - brightness) * geo.size.height
)
}
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(MatchColors.outline, lineWidth: 1))
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
saturation = min(max(value.location.x / geo.size.width, 0), 1)
brightness = 1 - min(max(value.location.y / geo.size.height, 0), 1)
emit()
}
)
}
.frame(height: 140)
}
private func emit() {
onColorChange(ColorHex.hsvToHex(hue: hue, saturation: saturation, brightness: brightness))
}
}
@@ -0,0 +1,166 @@
import SwiftUI
private let wizardStepTitles = ["01 · Partita", "02 · Trasmissione", "03 · Test rete"]
func wizardStepTitle(_ step: Int) -> String {
wizardStepTitles[min(max(step, 1), 3) - 1]
}
struct WizardStepIndicator: View {
let currentStep: Int
var body: some View {
HStack(spacing: 6) {
ForEach(1...3, id: \.self) { step in
RoundedRectangle(cornerRadius: 2)
.fill(step <= currentStep ? MatchColors.primaryRed : MatchColors.surfaceElevated)
.frame(height: 4)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
}
struct WizardFooterButtons: View {
var showBack: Bool = true
let onBack: () -> Void
let forwardLabel: String
var forwardLoading: Bool = false
let onForward: () -> Void
var body: some View {
HStack(spacing: 12) {
if showBack {
MatchSecondaryButton(label: "Indietro", action: onBack)
.frame(maxWidth: .infinity)
}
MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading)
.frame(maxWidth: .infinity)
}
}
}
struct WizardOutlinedField: View {
let label: String
@Binding var text: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
TextField("", text: $text)
.padding(12)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(MatchColors.outline, lineWidth: 1)
)
}
}
}
struct WizardReadOnlyField: View {
let label: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).font(MatchTypography.bodyMedium)
Text(value).font(MatchTypography.titleMedium)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(MatchColors.surfaceElevated, in: RoundedRectangle(cornerRadius: 10))
}
}
struct WizardChoiceButton: View {
let label: String
let selected: Bool
let action: () -> Void
var body: some View {
Group {
if selected {
MatchPrimaryButton(label: label, action: action)
} else {
MatchSecondaryButton(label: label, action: action)
}
}
}
}
struct MetricCard: View {
let label: String
let value: String
var highlight: Bool = false
var body: some View {
VStack(spacing: 4) {
Text(label)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
Text(value)
.font(MatchTypography.titleMedium)
.foregroundStyle(highlight ? MatchColors.successGreen : .white)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(
highlight ? MatchColors.successGreen.opacity(0.12) : MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 10)
)
}
}
struct WizardPlatformCard: View {
let title: String
let subtitle: String?
let selected: Bool
var enabled: Bool = true
var badge: String?
let onClick: () -> Void
var body: some View {
Button(action: onClick) {
HStack(alignment: .center, spacing: 12) {
Text(selected ? "" : "")
.foregroundStyle(selected ? MatchColors.primaryRed : MatchColors.textSecondary)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(MatchTypography.titleMedium)
.foregroundStyle(.white.opacity(enabled ? 1 : 0.55))
if let subtitle {
Text(subtitle)
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary.opacity(enabled ? 1 : 0.55))
}
}
Spacer(minLength: 8)
if let badge {
Text(badge)
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.textSecondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(MatchColors.surface, in: RoundedRectangle(cornerRadius: 6))
}
}
.padding(14)
.background(
selected ? MatchColors.primaryRed.opacity(0.15) : MatchColors.surfaceElevated,
in: RoundedRectangle(cornerRadius: 10)
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(selected ? MatchColors.primaryRed : .clear, lineWidth: 1)
)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.disabled(!enabled)
.opacity(enabled ? 1 : 0.55)
.frame(maxWidth: .infinity)
}
}
@@ -0,0 +1,113 @@
import SwiftUI
struct WizardShellScreen: View {
@ObservedObject var container: AppContainer
let matchId: String
let initialStep: Int
let onClose: () -> Void
let onStartLive: (String) -> Void
@State private var match: Match?
@State private var team: Team?
@State private var currentStep: Int
@State private var error: String?
init(container: AppContainer, matchId: String, step: Int, onClose: @escaping () -> Void, onStartLive: @escaping (String) -> Void) {
self.container = container
self.matchId = matchId
self.initialStep = step
self.onClose = onClose
self.onStartLive = onStartLive
_currentStep = State(initialValue: min(max(step, 1), 3))
}
var body: some View {
MatchScreenScaffold(
topBar: {
HStack(spacing: 12) {
Button(action: onClose) {
Image(systemName: "xmark").foregroundStyle(.white)
}
Text(wizardStepTitle(currentStep))
.font(MatchTypography.titleMedium)
Spacer(minLength: 0)
if currentStep == 1, let team {
MatchStatusBadge(text: team.sportKey.uppercased())
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
},
content: {
if let match {
VStack(spacing: 0) {
WizardStepIndicator(currentStep: currentStep)
Group {
switch currentStep {
case 1:
StepMatchScreen(
container: container,
match: match,
onNext: { currentStep = 2 },
onError: { presentError($0) }
)
case 2:
StepTransmissionScreen(
container: container,
match: match,
onBack: { currentStep = 1 },
onNext: { currentStep = 3 },
onError: { presentError($0) }
)
default:
if let session = container.wizardSession.session {
StepNetworkTestScreen(
container: container,
match: match,
session: session,
onBack: { currentStep = 2 },
onStartLive: onStartLive,
onError: { presentError($0) }
)
} else {
Text("Completa lo step Trasmissione")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} else {
ProgressView().tint(MatchColors.primaryRed)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
)
.task {
do {
let loaded = try await container.matchRepository.fetchMatch(matchId: matchId)
match = loaded
container.wizardSession.match = loaded
team = await container.matchRepository.fetchTeam(teamId: loaded.teamId)
container.wizardSession.team = team
} catch {
if let message = UserFacingError.message(for: error) {
presentError(message)
}
}
}
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {}
} message: {
Text(error ?? "")
}
}
private func presentError(_ message: String) {
guard !message.isEmpty else { return }
error = message
}
}