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:
co-authored by
Cursor
parent
b798e0ea06
commit
585332e32e
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user