Allinea iOS a i18n Android 2.0.5 e aggiunge monitoraggio termico nativo.
Completa L10n su login/hub/wizard/broadcast e introduce ThermalStateManager su iOS/Android con degradazione qualità e indicatore in overlay. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
ff4de33cc5
commit
ea5da1eb86
@@ -60,7 +60,7 @@ struct BroadcastControlsOverlay: View {
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
SideIconButton(
|
||||
systemName: controlsVisible ? "eye.slash.fill" : "eye.fill",
|
||||
accessibilityLabel: controlsVisible ? "Nascondi controlli" : "Mostra controlli",
|
||||
accessibilityLabel: controlsVisible ? L10n.t("broadcast.hide.controls.cd") : L10n.t("broadcast.show.controls.cd"),
|
||||
action: onToggleControls
|
||||
)
|
||||
BroadcastTelemetryPanel(
|
||||
@@ -94,11 +94,11 @@ struct BroadcastControlsOverlay: View {
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
.alert("Terminare la diretta?", isPresented: $showTerminateConfirm) {
|
||||
Button("Annulla", role: .cancel) {}
|
||||
Button("TERMINA", role: .destructive, action: onTerminate)
|
||||
.alert(L10n.t("broadcast.terminate.title"), isPresented: $showTerminateConfirm) {
|
||||
Button(L10n.t("action.cancel"), role: .cancel) {}
|
||||
Button(L10n.t("broadcast.terminate.confirm"), role: .destructive, action: onTerminate)
|
||||
} message: {
|
||||
Text("Lo streaming verrà chiuso per tutti gli spettatori.")
|
||||
Text(L10n.t("broadcast.terminate.message"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,26 +106,26 @@ struct BroadcastControlsOverlay: View {
|
||||
VStack(spacing: 6) {
|
||||
SideIconButton(
|
||||
systemName: "square.and.arrow.up",
|
||||
accessibilityLabel: "Condividi diretta",
|
||||
accessibilityLabel: L10n.t("broadcast.share.live.cd"),
|
||||
action: onShareLive,
|
||||
enabled: shareLiveEnabled
|
||||
)
|
||||
SideIconButton(
|
||||
systemName: "video.fill",
|
||||
accessibilityLabel: "Condividi link regia",
|
||||
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
||||
action: onShareRegia
|
||||
)
|
||||
if let onCloseSet {
|
||||
SideIconButton(
|
||||
systemName: "checkmark",
|
||||
accessibilityLabel: "Chiudi set",
|
||||
accessibilityLabel: L10n.t("score.action.close.set"),
|
||||
action: onCloseSet
|
||||
)
|
||||
}
|
||||
if let onAdvancePeriod {
|
||||
SideIconButton(
|
||||
systemName: "forward.end.fill",
|
||||
accessibilityLabel: "Periodo successivo",
|
||||
accessibilityLabel: L10n.t("broadcast.next.period.cd"),
|
||||
action: onAdvancePeriod
|
||||
)
|
||||
}
|
||||
@@ -138,13 +138,13 @@ struct BroadcastControlsOverlay: View {
|
||||
VStack(spacing: 6) {
|
||||
SideIconButton(
|
||||
systemName: isPaused ? "play.fill" : "pause.fill",
|
||||
accessibilityLabel: isPaused ? "Riprendi diretta" : "Pausa diretta",
|
||||
accessibilityLabel: isPaused ? L10n.t("broadcast.resume.cd") : L10n.t("broadcast.pause.cd"),
|
||||
action: onPauseOrResume,
|
||||
highlighted: isPaused
|
||||
)
|
||||
SideIconButton(
|
||||
systemName: "stop.fill",
|
||||
accessibilityLabel: "Termina diretta",
|
||||
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
|
||||
action: { showTerminateConfirm = true },
|
||||
danger: true
|
||||
)
|
||||
@@ -154,7 +154,7 @@ struct BroadcastControlsOverlay: View {
|
||||
private var scoreControlsRow: some View {
|
||||
HStack(alignment: .bottom, spacing: 0) {
|
||||
TeamScoreColumn(
|
||||
teamLabel: "CASA",
|
||||
teamLabel: L10n.t("broadcast.team.home.label"),
|
||||
teamName: homeName,
|
||||
accentColor: homeAccentColor,
|
||||
logoUrl: homeLogoUrl,
|
||||
@@ -171,7 +171,7 @@ struct BroadcastControlsOverlay: View {
|
||||
ScoreCenterPanel(score: score, boardType: boardType, pointsTarget: pointsTarget)
|
||||
|
||||
TeamScoreColumn(
|
||||
teamLabel: "OSPITE",
|
||||
teamLabel: L10n.t("broadcast.team.away.label"),
|
||||
teamName: awayName,
|
||||
accentColor: awayAccentColor,
|
||||
logoUrl: awayLogoUrl,
|
||||
@@ -198,7 +198,7 @@ private struct BroadcastTelemetryPanel: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(cableConnected ? "Tabellone OK" : "Tabellone offline")
|
||||
Text(cableConnected ? L10n.t("broadcast.scoreboard.connected") : L10n.t("broadcast.scoreboard.offline"))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(cableConnected ? MatchColors.successGreen : MatchColors.textSecondary)
|
||||
let fpsLabel = fps > 0 ? "\(fps) fps" : "— fps"
|
||||
@@ -214,11 +214,7 @@ private struct BroadcastTelemetryPanel: View {
|
||||
Text("\(networkType) · \(deviceHealth.batteryPercent)%")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
ThermalIndicator(
|
||||
tempC: deviceHealth.batteryTempC,
|
||||
level: deviceHealth.thermalLevel,
|
||||
label: deviceHealth.thermalLabel
|
||||
)
|
||||
ThermalIndicator(state: deviceHealth.thermalState)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
@@ -227,31 +223,27 @@ private struct BroadcastTelemetryPanel: View {
|
||||
}
|
||||
|
||||
private struct ThermalIndicator: View {
|
||||
let tempC: Double?
|
||||
let level: Int
|
||||
let label: String
|
||||
let state: ThermalState
|
||||
|
||||
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
|
||||
switch state {
|
||||
case .nominal: return MatchColors.successGreen
|
||||
case .fair: return MatchColors.accentYellow
|
||||
case .serious: return Color.orange
|
||||
case .critical: return MatchColors.primaryRed
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let tempText = tempC.map { "\(Int($0))°C" } ?? "—°C"
|
||||
let warningBg = level >= 1 ? color.opacity(0.18) : Color.clear
|
||||
let warningBg = state >= .fair ? color.opacity(0.18) : Color.clear
|
||||
HStack(spacing: 4) {
|
||||
Text(tempText)
|
||||
.font(.system(size: 11, weight: level >= 1 ? .bold : .regular))
|
||||
Text(state.indicatorSymbol)
|
||||
.font(.system(size: 11))
|
||||
Text(state.displayLabel)
|
||||
.font(.system(size: 11, weight: state >= .fair ? .bold : .medium))
|
||||
.foregroundStyle(color)
|
||||
if level >= 1 {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 1)
|
||||
@@ -272,17 +264,17 @@ private struct ScoreCenterPanel: View {
|
||||
.fontWeight(.bold)
|
||||
switch boardType {
|
||||
case "basket", "timed":
|
||||
Text(score.periodLabel ?? (boardType == "basket" ? "Q\(score.period)" : "\(score.period)° tempo"))
|
||||
Text(score.periodLabel ?? (boardType == "basket" ? "Q\(score.period)" : L10n.t("broadcast.period.label.timed", score.period)))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
case "generic":
|
||||
EmptyView()
|
||||
default:
|
||||
Text("Set \(score.currentSet) · \(pointsTarget) pt")
|
||||
Text(L10n.t("broadcast.set.progress", score.currentSet, pointsTarget))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
if score.homeSets > 0 || score.awaySets > 0 {
|
||||
Text("Set vinti \(score.homeSets)-\(score.awaySets)")
|
||||
Text(L10n.t("broadcast.sets.won", score.homeSets, score.awaySets))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
@@ -322,28 +314,28 @@ private struct TeamScoreColumn: View {
|
||||
.fontWeight(.bold)
|
||||
.padding(.vertical, 4)
|
||||
Spacer().frame(height: 4)
|
||||
let teamSide = alignEnd ? "ospite" : "casa"
|
||||
let teamSide = alignEnd ? L10n.t("broadcast.side.away") : L10n.t("broadcast.side.home")
|
||||
HStack(spacing: 6) {
|
||||
if alignEnd {
|
||||
if showBasketButtons {
|
||||
if let onPlus3 {
|
||||
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
|
||||
ScoreIconButton(label: "+3", tooltip: L10n.t("broadcast.tooltip.plus.side", 3, teamSide), action: onPlus3, primary: true)
|
||||
}
|
||||
if let onPlus2 {
|
||||
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
|
||||
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 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)
|
||||
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
|
||||
ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
|
||||
} else {
|
||||
ScoreIconButton(label: "−", tooltip: "Togli punto \(teamSide)", action: onMinus)
|
||||
ScoreIconButton(label: "+1", tooltip: "Aggiungi punto \(teamSide)", action: onPlus, primary: !showBasketButtons)
|
||||
ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
|
||||
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
|
||||
if showBasketButtons {
|
||||
if let onPlus2 {
|
||||
ScoreIconButton(label: "+2", tooltip: "+2 \(teamSide)", action: onPlus2, primary: true)
|
||||
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true)
|
||||
}
|
||||
if let onPlus3 {
|
||||
ScoreIconButton(label: "+3", tooltip: "+3 \(teamSide)", action: onPlus3, primary: true)
|
||||
ScoreIconButton(label: "+3", tooltip: L10n.t("broadcast.tooltip.plus.side", 3, teamSide), action: onPlus3, primary: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ struct BroadcastScreen: View {
|
||||
|
||||
@StateObject private var permissions = BroadcastPermissions()
|
||||
@StateObject private var scoreDialogHost = LiveScoreDialogHost()
|
||||
@StateObject private var thermalManager = ThermalStateManager()
|
||||
|
||||
init(container: AppContainer, sessionId: String, onFinished: @escaping () -> Void) {
|
||||
self.container = container
|
||||
@@ -30,6 +31,7 @@ struct BroadcastScreen: View {
|
||||
@State private var snackbarMessage: String?
|
||||
@State private var shareItem: ShareItem?
|
||||
@State private var bootstrapGeneration = 0
|
||||
@State private var languageTick = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -44,11 +46,11 @@ struct BroadcastScreen: View {
|
||||
ProgressView().tint(MatchColors.primaryRed)
|
||||
} else if !permissions.allGranted {
|
||||
VStack(spacing: 12) {
|
||||
Text("Consenti camera e microfono per andare in diretta")
|
||||
Text(L10n.t("broadcast.permissions.required"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.accentYellow)
|
||||
.multilineTextAlignment(.center)
|
||||
MatchPrimaryButton(label: "CONCEDI PERMESSI") {
|
||||
MatchPrimaryButton(label: L10n.t("broadcast.permissions.grant.action")) {
|
||||
Task { await permissions.requestAll() }
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
@@ -56,7 +58,7 @@ struct BroadcastScreen: View {
|
||||
.padding(24)
|
||||
} else if let session, let match {
|
||||
broadcastOverlay(session: session, match: match)
|
||||
.id(scoreController.score.progressKey())
|
||||
.id("\(scoreController.score.progressKey())-\(languageTick)")
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
@@ -89,7 +91,7 @@ struct BroadcastScreen: View {
|
||||
.task(id: sessionId) {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
deviceHealth = DeviceTelemetry.snapshot()
|
||||
deviceHealth = DeviceTelemetry.snapshot(thermalState: thermalManager.state)
|
||||
}
|
||||
}
|
||||
.task(id: sessionId) {
|
||||
@@ -116,11 +118,35 @@ struct BroadcastScreen: View {
|
||||
self.error = message
|
||||
}
|
||||
}
|
||||
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("Riprova") {
|
||||
.onChange(of: thermalManager.state) { _ in
|
||||
deviceHealth = DeviceTelemetry.snapshot(thermalState: thermalManager.state)
|
||||
}
|
||||
.onChange(of: thermalManager.noticeMessage) { message in
|
||||
if let message { snackbarMessage = message }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||
// Aggiorna solo i testi dell'overlay (vedi .id su broadcastOverlay):
|
||||
// non deve smontare l'engine di broadcast né la sessione in corso.
|
||||
languageTick += 1
|
||||
}
|
||||
.alert(L10n.t("thermal.alert.title"), isPresented: Binding(
|
||||
get: { thermalManager.showCriticalAlert },
|
||||
set: { if !$0 { thermalManager.acknowledgeCriticalAlert() } }
|
||||
)) {
|
||||
Button(L10n.t("thermal.alert.stop"), role: .destructive) {
|
||||
Task { await stopStream() }
|
||||
}
|
||||
Button(L10n.t("thermal.alert.continue"), role: .cancel) {
|
||||
thermalManager.acknowledgeCriticalAlert()
|
||||
}
|
||||
} message: {
|
||||
Text(L10n.t("thermal.alert.message"))
|
||||
}
|
||||
.alert(L10n.t("common.error.title"), isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button(L10n.t("matches.retry")) {
|
||||
Task { await retryBroadcast() }
|
||||
}
|
||||
Button("Esci", role: .destructive) { onFinished() }
|
||||
Button(L10n.t("action.exit"), role: .destructive) { onFinished() }
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
@@ -264,7 +290,7 @@ struct BroadcastScreen: View {
|
||||
targetFps: session.targetFps,
|
||||
bitrateKbps: metrics.bitrateKbps,
|
||||
networkType: deviceHealth.networkType,
|
||||
deviceHealth: deviceHealth
|
||||
deviceHealth: DeviceTelemetry.snapshot(thermalState: thermalManager.state)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -279,13 +305,13 @@ struct BroadcastScreen: View {
|
||||
}
|
||||
|
||||
private func broadcastStatusText(isPaused: Bool, metrics: BroadcastMetrics) -> String {
|
||||
if isPaused { return "PAUSA" }
|
||||
if isPaused { return L10n.t("broadcast.status.paused") }
|
||||
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"
|
||||
case .live: return L10n.t("broadcast.status.live")
|
||||
case .connecting: return L10n.t("broadcast.status.connecting")
|
||||
case .reconnecting: return L10n.t("broadcast.status.reconnecting")
|
||||
case .error: return metrics.lastError ?? L10n.t("broadcast.status.error.fallback")
|
||||
default: return L10n.t("broadcast.status.preview")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +328,7 @@ struct BroadcastScreen: View {
|
||||
private func shareLiveLink(session: StreamSession, subject: String) {
|
||||
let urlString = session.watchShareUrl() ?? "\(AppConfig.apiBaseUrl)/live/\(session.id)"
|
||||
guard let url = URL(string: urlString) else {
|
||||
snackbarMessage = "Link diretta non ancora disponibile"
|
||||
snackbarMessage = L10n.t("broadcast.share.link.unavailable")
|
||||
return
|
||||
}
|
||||
shareItem = ShareItem(items: [url], subject: subject)
|
||||
@@ -313,12 +339,12 @@ struct BroadcastScreen: View {
|
||||
do {
|
||||
let urlString = try await container.sessionRepository.createRegiaLink(sessionId: sessionId)
|
||||
guard let url = URL(string: urlString) else {
|
||||
snackbarMessage = "Link regia non valido"
|
||||
snackbarMessage = L10n.t("broadcast.error.regia.link.invalid")
|
||||
return
|
||||
}
|
||||
shareItem = ShareItem(items: [url], subject: "Link regia — \(subject)")
|
||||
shareItem = ShareItem(items: [url], subject: L10n.t("broadcast.share.regia.subject", subject))
|
||||
} catch {
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? "Errore link regia"
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? L10n.t("broadcast.error.regia.link")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,11 +371,14 @@ struct BroadcastScreen: View {
|
||||
loading = false
|
||||
guard generation == bootstrapGeneration else { return }
|
||||
guard await container.broadcastCoordinator.waitForPreviewSurface() else {
|
||||
throw APIError.http(500, "Anteprima camera non pronta")
|
||||
throw APIError.http(500, L10n.t("broadcast.error.camera.preview.not.ready"))
|
||||
}
|
||||
guard generation == bootstrapGeneration else { return }
|
||||
if let url = loaded.rtmpIngestUrl, !url.isEmpty {
|
||||
let config = broadcastConfig(for: loaded, rtmpUrl: url)
|
||||
thermalManager.bind(engine: container.broadcastCoordinator.engine)
|
||||
thermalManager.updateBaseline(config)
|
||||
thermalManager.start()
|
||||
try await container.broadcastCoordinator.prepareBroadcast(
|
||||
sessionId: sessionId,
|
||||
config: config,
|
||||
@@ -408,7 +437,7 @@ struct BroadcastScreen: View {
|
||||
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")
|
||||
let period = score.periodLabel ?? (overlayKind == .basket ? "Q\(score.period)" : L10n.t("broadcast.period.label.timed", score.period))
|
||||
state = OverlayState(
|
||||
overlayKind: overlayKind,
|
||||
compactScoreboard: CompactScoreboardState(
|
||||
@@ -450,9 +479,9 @@ struct BroadcastScreen: View {
|
||||
let updated = try await container.sessionRepository.pauseSession(id: sessionId)
|
||||
session = updated
|
||||
await container.broadcastCoordinator.pauseBroadcast()
|
||||
snackbarMessage = "Diretta in pausa"
|
||||
snackbarMessage = L10n.t("broadcast.snackbar.paused")
|
||||
} catch {
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? "Pausa non riuscita"
|
||||
snackbarMessage = L10n.t("broadcast.snackbar.pause.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,9 +498,9 @@ struct BroadcastScreen: View {
|
||||
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
||||
updated = try await container.sessionRepository.fetchSession(id: sessionId)
|
||||
session = updated
|
||||
snackbarMessage = "Diretta ripresa"
|
||||
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
|
||||
} catch {
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? "Ripresa non riuscita"
|
||||
snackbarMessage = L10n.t("broadcast.snackbar.resume.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +513,7 @@ struct BroadcastScreen: View {
|
||||
if let fetched = try? await container.sessionRepository.fetchSession(id: sessionId) {
|
||||
session = fetched
|
||||
}
|
||||
snackbarMessage = "Pausa dalla regia"
|
||||
snackbarMessage = L10n.t("broadcast.snackbar.paused.remote")
|
||||
}
|
||||
|
||||
/// Ripresa dalla regia / echo cable: solo RTMP locale, senza PATCH resume.
|
||||
@@ -497,10 +526,11 @@ struct BroadcastScreen: View {
|
||||
session = current
|
||||
guard let url = current.rtmpIngestUrl, !url.isEmpty else { return }
|
||||
let config = broadcastConfig(for: current, rtmpUrl: url)
|
||||
thermalManager.updateBaseline(config)
|
||||
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
||||
snackbarMessage = "Diretta ripresa"
|
||||
snackbarMessage = L10n.t("broadcast.snackbar.resumed")
|
||||
} catch {
|
||||
snackbarMessage = UserFacingError.message(for: error) ?? "Ripresa RTMP non riuscita"
|
||||
snackbarMessage = L10n.t("broadcast.snackbar.resume.rtmp.error", UserFacingError.message(for: error) ?? L10n.t("common.error.generic"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,11 +545,12 @@ struct BroadcastScreen: View {
|
||||
error = nil
|
||||
guard let session, let url = session.rtmpIngestUrl, !url.isEmpty else { return }
|
||||
let config = broadcastConfig(for: session, rtmpUrl: url)
|
||||
thermalManager.updateBaseline(config)
|
||||
do {
|
||||
try await container.broadcastCoordinator.resumeBroadcast(config: config)
|
||||
snackbarMessage = "Riconnessione avviata"
|
||||
snackbarMessage = L10n.t("broadcast.snackbar.reconnect.started")
|
||||
} catch {
|
||||
self.error = UserFacingError.message(for: error) ?? "Riconnessione non riuscita"
|
||||
self.error = UserFacingError.message(for: error) ?? L10n.t("broadcast.error.reconnect.failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,6 +561,7 @@ struct BroadcastScreen: View {
|
||||
}
|
||||
|
||||
private func teardown() async {
|
||||
thermalManager.stop()
|
||||
container.scoreController.onScoreDidChange = nil
|
||||
container.sessionCable.onScoreUpdate = nil
|
||||
container.sessionCable.onPauseStream = nil
|
||||
|
||||
@@ -144,25 +144,25 @@ struct ScoreDialogRouter: View {
|
||||
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) }
|
||||
title: Text(L10n.t("score.set.won.title")),
|
||||
message: Text(L10n.t("score.set.won.message", winner, dialog.homePoints, dialog.awayPoints)),
|
||||
primaryButton: .default(Text(L10n.t("score.action.close.set"))) { host.resolve(true) },
|
||||
secondaryButton: .cancel(Text(L10n.t("score.action.continue.scoring"))) { 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) }
|
||||
title: Text(L10n.t("score.action.close.set")),
|
||||
message: Text(L10n.t("score.close.set.anyway.message")),
|
||||
primaryButton: .default(Text(L10n.t("score.action.close.anyway"))) { host.resolve(true) },
|
||||
secondaryButton: .cancel(Text(L10n.t("action.cancel"))) { 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) }
|
||||
title: Text(L10n.t("score.match.won.title")),
|
||||
message: Text(L10n.t("score.match.won.message", winner, dialog.homeSets, dialog.awaySets)),
|
||||
primaryButton: .default(Text(L10n.t("score.action.close.live"))) { host.resolve(true) },
|
||||
secondaryButton: .cancel(Text(L10n.t("score.action.continue.live"))) { host.resolve(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ struct MatchLiveWordmark: View {
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
if showSlogan {
|
||||
Text("OGNI PARTITA, OGNI EVENTO, PER I TUOI TIFOSI.")
|
||||
Text(L10n.t("app.slogan").uppercased())
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -16,18 +16,24 @@ struct LoginScreen: View {
|
||||
MatchScreenScaffold {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
MatchLiveWordmark(showSlogan: true)
|
||||
.padding(.top, 32)
|
||||
Button(L10n.t("language.label")) {
|
||||
showLanguagePicker = true
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
showLanguagePicker = true
|
||||
} label: {
|
||||
Image(systemName: "globe")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("language.label"))
|
||||
}
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.top, 16)
|
||||
.padding(.top, 8)
|
||||
MatchLiveWordmark(showSlogan: true)
|
||||
.padding(.top, 16)
|
||||
Text(L10n.t("login.submit").uppercased())
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.padding(.top, 32)
|
||||
VStack(spacing: 16) {
|
||||
MatchTextField(title: L10n.t("login.email"), text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
|
||||
MatchTextField(title: L10n.t("login.email"), text: $email, placeholder: L10n.t("login.email.placeholder"), keyboard: .emailAddress)
|
||||
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
|
||||
@@ -36,13 +36,16 @@ struct MatchesScreen: View {
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("language.label"))
|
||||
Button(L10n.t("action.logout")) {
|
||||
Button {
|
||||
Task {
|
||||
await container.authRepository.logout()
|
||||
onLogout()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.accessibilityLabel(L10n.t("action.logout"))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
@@ -53,29 +56,33 @@ struct MatchesScreen: View {
|
||||
ProgressView().tint(MatchColors.primaryRed)
|
||||
} else if teams.isEmpty {
|
||||
VStack(spacing: 16) {
|
||||
Text("Nessuna squadra disponibile")
|
||||
Text(L10n.t("matches.no.team.title"))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
MatchPrimaryButton(label: "RIPROVA", action: { reload(showSpinner: false) })
|
||||
Text(L10n.t("matches.no.team.body"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
MatchPrimaryButton(label: L10n.t("matches.retry").uppercased(), 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) })
|
||||
MatchPrimaryButton(label: L10n.t("matches.retry").uppercased(), 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 ?? "")")
|
||||
Text(L10n.t("matches.hello", container.tokenStore.session?.user.name ?? ""))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
Text("Riprendi una diretta in corso o avvia una partita programmata.")
|
||||
Text(L10n.t("matches.subtitle"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
HStack(spacing: 12) {
|
||||
MatchSecondaryButton(label: "PARTITA PROGRAMMATA", action: { showSchedule = true })
|
||||
MatchPrimaryButton(label: "NUOVA PARTITA", action: { showNewMatch = true })
|
||||
MatchSecondaryButton(label: L10n.t("matches.schedule").uppercased(), action: { showSchedule = true })
|
||||
MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true })
|
||||
}
|
||||
if let activeTeam {
|
||||
TeamPickerBar(
|
||||
@@ -108,7 +115,7 @@ struct MatchesScreen: View {
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
} else if calendarMatches.isEmpty {
|
||||
Text("Nessuna altra partita in calendario.")
|
||||
Text(L10n.t("matches.no.other"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -136,27 +143,41 @@ struct MatchesScreen: View {
|
||||
}
|
||||
)
|
||||
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
|
||||
.alert("Riprendi diretta?", isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
|
||||
Button("Riprendi") {
|
||||
.alert(L10n.t("sheet.configure.now.title"), isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
|
||||
Button(L10n.t("sheet.resume.camera")) {
|
||||
if let match = resumeMatch { resumeBroadcast(match) }
|
||||
resumeMatch = nil
|
||||
}
|
||||
Button("Configura", role: .cancel) {
|
||||
Button(L10n.t("sheet.configure"), role: .cancel) {
|
||||
if let match = resumeMatch { onOpenSetup(match.id) }
|
||||
resumeMatch = nil
|
||||
}
|
||||
} message: {
|
||||
Text(L10n.t("sheet.configure.now.body"))
|
||||
}
|
||||
.alert("Elimina partita?", isPresented: Binding(get: { deleteMatch != nil }, set: { if !$0 { deleteMatch = nil } })) {
|
||||
Button("Elimina", role: .destructive) {
|
||||
.alert(
|
||||
L10n.t("sheet.delete.match.title"),
|
||||
isPresented: Binding(get: { deleteMatch != nil }, set: { if !$0 { deleteMatch = nil } })
|
||||
) {
|
||||
Button(L10n.t("sheet.delete"), role: .destructive) {
|
||||
if let match = deleteMatch {
|
||||
Task {
|
||||
try? await container.matchRepository.deleteMatch(matchId: match.id)
|
||||
reload(showSpinner: false)
|
||||
do {
|
||||
try await container.matchRepository.deleteMatch(matchId: match.id)
|
||||
snackbar = L10n.t("matches.msg.deleted")
|
||||
reload(showSpinner: false)
|
||||
} catch {
|
||||
snackbar = L10n.t("matches.msg.delete.failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
deleteMatch = nil
|
||||
}
|
||||
Button("Annulla", role: .cancel) { deleteMatch = nil }
|
||||
Button(L10n.t("action.cancel"), role: .cancel) { deleteMatch = nil }
|
||||
} message: {
|
||||
if let match = deleteMatch {
|
||||
Text(L10n.t("sheet.delete.match.body", match.teamName, match.opponentName))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showNewMatch) {
|
||||
NewMatchSheet(
|
||||
@@ -229,21 +250,21 @@ struct MatchesScreen: View {
|
||||
|
||||
private var calendarSectionTitle: String {
|
||||
if calendarMatches.isEmpty && activeMatch == nil {
|
||||
return "Nessuna partita in calendario"
|
||||
return L10n.t("matches.empty.title")
|
||||
}
|
||||
if !scheduledMatches.isEmpty {
|
||||
return "Partite programmate"
|
||||
return L10n.t("matches.scheduled.title")
|
||||
}
|
||||
return "Pronte da avviare"
|
||||
return L10n.t("matches.ready.title")
|
||||
}
|
||||
|
||||
private var emptyCalendarMessage: String {
|
||||
var message = "Programma una partita o avviane una nuova con «Nuova partita»."
|
||||
var message = L10n.t("matches.empty.hint")
|
||||
if let teamName = activeTeam?.name {
|
||||
message += "\n\nSquadra attiva: \(teamName)."
|
||||
message += "\n\n" + L10n.t("matches.active.team", teamName)
|
||||
}
|
||||
if teams.count > 1 {
|
||||
message += "\nHai più squadre: verifica quella selezionata sopra."
|
||||
message += "\n" + L10n.t("matches.multi.team.hint")
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -260,7 +281,7 @@ struct MatchesScreen: View {
|
||||
matches = try await container.matchRepository.fetchMatchesForTeam(teamId: team.id)
|
||||
}
|
||||
} catch {
|
||||
self.error = UserFacingError.message(for: error)
|
||||
self.error = UserFacingError.message(for: error) ?? L10n.t("matches.msg.load.error")
|
||||
}
|
||||
loading = false
|
||||
refreshing = false
|
||||
@@ -279,7 +300,7 @@ struct MatchesScreen: View {
|
||||
let sessionId = try await MatchSessionLauncher.resumeBroadcastSession(match: match, sessionRepository: container.sessionRepository)
|
||||
onOpenBroadcast(sessionId)
|
||||
} catch {
|
||||
snackbar = UserFacingError.message(for: error)
|
||||
snackbar = UserFacingError.message(for: error) ?? L10n.t("matches.msg.resume.failed")
|
||||
}
|
||||
actionLoading = false
|
||||
}
|
||||
@@ -294,7 +315,7 @@ struct MatchesScreen: View {
|
||||
reload(showSpinner: false)
|
||||
onOpenSetup(match.id)
|
||||
} catch {
|
||||
snackbar = UserFacingError.message(for: error)
|
||||
snackbar = UserFacingError.message(for: error) ?? L10n.t("matches.msg.create.failed")
|
||||
}
|
||||
actionLoading = false
|
||||
}
|
||||
@@ -335,7 +356,7 @@ private struct ActiveSessionBanner: View {
|
||||
Image(systemName: "video.fill")
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Riprendi diretta in corso")
|
||||
Text(L10n.t("sheet.resume.banner"))
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
Text("\(match.teamName) vs \(match.opponentName)")
|
||||
@@ -409,23 +430,23 @@ private struct NewMatchSheet: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Nuova partita")
|
||||
Text(L10n.t("sheet.new.match.title"))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
Text("Programma in anticipo o avvia la configurazione diretta subito.")
|
||||
Text(L10n.t("sheet.new.match.lead"))
|
||||
.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",
|
||||
title: L10n.t("sheet.schedule.option"),
|
||||
subtitle: L10n.t("sheet.schedule.option.sub"),
|
||||
action: onSchedule
|
||||
)
|
||||
SheetOptionTile(
|
||||
systemImage: "play.circle",
|
||||
title: "Avvia subito",
|
||||
subtitle: "Crea la partita e passa al wizard senza orario",
|
||||
title: L10n.t("sheet.quick.option"),
|
||||
subtitle: L10n.t("sheet.quick.option.sub"),
|
||||
action: onQuickStart
|
||||
)
|
||||
}
|
||||
@@ -481,15 +502,18 @@ private struct ScheduleMatchSheet: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
TextField("Avversario", text: $opponent)
|
||||
TextField("Luogo", text: $location)
|
||||
DatePicker("Data", selection: $date)
|
||||
TextField(L10n.t("sheet.opponent"), text: $opponent)
|
||||
TextField(L10n.t("sheet.location.optional"), text: $location)
|
||||
DatePicker(L10n.t("sheet.date.time"), selection: $date)
|
||||
}
|
||||
.navigationTitle("Partita programmata")
|
||||
.navigationTitle(L10n.t("sheet.schedule.title"))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Chiudi") { dismiss() } }
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.t("common.close")) { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Crea") { create() }.disabled(opponent.isEmpty || teamId == nil)
|
||||
Button(L10n.t("sheet.save.schedule")) { create() }
|
||||
.disabled(opponent.isEmpty || teamId == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -498,13 +522,16 @@ private struct ScheduleMatchSheet: View {
|
||||
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
|
||||
) {
|
||||
do {
|
||||
let match = try await container.matchRepository.createScheduledMatch(
|
||||
teamId: teamId,
|
||||
opponentName: opponent,
|
||||
scheduledAt: date,
|
||||
location: location.nilIfEmpty
|
||||
)
|
||||
onCreated(match)
|
||||
} catch {
|
||||
// Sheet chiude solo su successo; errori gestiti dal chiamante via snackbar se necessario.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -532,7 +559,7 @@ private struct TeamPickerSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Squadra")
|
||||
.navigationTitle(L10n.t("sheet.choose.team"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ struct AppNavHost: View {
|
||||
@State private var broadcastRoute: BroadcastRoute?
|
||||
/// Incrementato al ritorno da wizard/broadcast per ricaricare l'hub partite.
|
||||
@State private var matchesRefreshToken = 0
|
||||
@State private var languageTick = 0
|
||||
|
||||
/// Locale SwiftUI (DatePicker, ecc.) aggiornata al cambio lingua senza resettare la NavigationStack.
|
||||
private var appLocale: Locale {
|
||||
_ = languageTick
|
||||
return AppLanguage.locale
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
@@ -39,6 +46,10 @@ struct AppNavHost: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.locale, appLocale)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||
languageTick += 1
|
||||
}
|
||||
.lockPortraitOrientation()
|
||||
.fullScreenCover(item: $wizardRoute) { route in
|
||||
WizardShellScreen(
|
||||
|
||||
@@ -124,12 +124,12 @@ struct StepMatchScreen: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Dettagli partita")
|
||||
Text(L10n.t("wizard.match.details.title"))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
TeamBrandingRow(
|
||||
sectionLabel: "Squadra di casa",
|
||||
sectionLabel: L10n.t("wizard.match.home.team.label"),
|
||||
teamName: .constant(match.teamName),
|
||||
nameEditable: false,
|
||||
remoteLogoUrl: homeLogoUrl,
|
||||
@@ -140,7 +140,7 @@ struct StepMatchScreen: View {
|
||||
.padding(.bottom, 16)
|
||||
|
||||
TeamBrandingRow(
|
||||
sectionLabel: "Squadra avversaria",
|
||||
sectionLabel: L10n.t("wizard.match.away.team.label"),
|
||||
teamName: $opponent,
|
||||
nameEditable: true,
|
||||
remoteLogoUrl: opponentLogoUrl,
|
||||
@@ -150,25 +150,25 @@ struct StepMatchScreen: View {
|
||||
)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
WizardOutlinedField(label: "Luogo", text: $location)
|
||||
WizardOutlinedField(label: L10n.t("wizard.match.location.label"), text: $location)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
WizardOutlinedField(label: "Campionato (facoltativo)", text: $campionato)
|
||||
Text("Es. Serie C, torneo estivo — lo useremo in descrizione e overlay.")
|
||||
WizardOutlinedField(label: L10n.t("wizard.match.category.label"), text: $campionato)
|
||||
Text(L10n.t("wizard.match.category.hint"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
if isScheduledMatch {
|
||||
WizardReadOnlyField(
|
||||
label: "Programmata per",
|
||||
label: L10n.t("wizard.match.scheduled.label"),
|
||||
value: ApiInstant.formatMatchDate(match.scheduledAt) ?? "—"
|
||||
)
|
||||
.padding(.top, 16)
|
||||
}
|
||||
|
||||
if !allowedOverlays.isEmpty {
|
||||
Toggle("Overlay video personalizzato", isOn: $customOverlay)
|
||||
Toggle(L10n.t("wizard.match.custom.overlay.label"), isOn: $customOverlay)
|
||||
.padding(.top, 20)
|
||||
if customOverlay {
|
||||
VStack(spacing: 6) {
|
||||
@@ -186,7 +186,7 @@ struct StepMatchScreen: View {
|
||||
|
||||
Toggle(isOn: $customRules) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Regole punteggio personalizzate")
|
||||
Text(L10n.t("wizard.match.custom.rules.label"))
|
||||
Text(customRulesDescription)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
@@ -202,7 +202,7 @@ struct StepMatchScreen: View {
|
||||
volleyRulesSection
|
||||
}
|
||||
|
||||
MatchPrimaryButton(label: "AVANTI >", action: saveAndContinue, enabled: !saving, loading: saving)
|
||||
MatchPrimaryButton(label: L10n.t("wizard.action.next"), action: saveAndContinue, enabled: !saving, loading: saving)
|
||||
.padding(.top, 32)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -244,20 +244,20 @@ struct StepMatchScreen: View {
|
||||
|
||||
private var customRulesDescription: String {
|
||||
if !customRules && ["basket", "timed"].contains(boardType) {
|
||||
return "Regole standard dello sport selezionato."
|
||||
return L10n.t("wizard.match.rules.standard.sport")
|
||||
}
|
||||
if customRules && ["basket", "timed"].contains(boardType) {
|
||||
return "Torneo non standard: tempi e periodi personalizzati."
|
||||
return L10n.t("wizard.match.rules.custom.timed")
|
||||
}
|
||||
if customRules {
|
||||
return "Torneo non standard: imposta set e punteggi."
|
||||
return L10n.t("wizard.match.rules.custom.sets")
|
||||
}
|
||||
return "Standard FIPAV: 3 set per vincere, set a 25, tie-break a 15."
|
||||
return L10n.t("wizard.match.rules.standard.sets")
|
||||
}
|
||||
|
||||
private var periodRulesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(boardType == "basket" ? "Quarti" : "Tempi")
|
||||
Text(boardType == "basket" ? L10n.t("wizard.match.periods.basket.label") : L10n.t("wizard.match.periods.timed.label"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.padding(.top, 16)
|
||||
HStack(spacing: 8) {
|
||||
@@ -271,7 +271,7 @@ struct StepMatchScreen: View {
|
||||
}
|
||||
}
|
||||
WizardOutlinedField(
|
||||
label: boardType == "basket" ? "Minuti per quarto" : "Minuti per tempo",
|
||||
label: boardType == "basket" ? L10n.t("wizard.match.minutes.per.period.basket") : L10n.t("wizard.match.minutes.per.period.timed"),
|
||||
text: $periodDurationText
|
||||
)
|
||||
.onChange(of: periodDurationText) { text in
|
||||
@@ -279,7 +279,7 @@ struct StepMatchScreen: View {
|
||||
if filtered != text { periodDurationText = filtered }
|
||||
if let value = Int(filtered) { periodDurationMins = min(max(value, 1), 120) }
|
||||
}
|
||||
WizardOutlinedField(label: "Minuti supplementari", text: $overtimeDurationText)
|
||||
WizardOutlinedField(label: L10n.t("wizard.match.overtime.minutes.label"), text: $overtimeDurationText)
|
||||
.onChange(of: overtimeDurationText) { text in
|
||||
let filtered = String(text.filter(\.isNumber).prefix(3))
|
||||
if filtered != text { overtimeDurationText = filtered }
|
||||
@@ -290,7 +290,7 @@ struct StepMatchScreen: View {
|
||||
|
||||
private var volleyRulesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Set da vincere la partita")
|
||||
Text(L10n.t("wizard.match.sets.to.win.label"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.padding(.top, 16)
|
||||
HStack(spacing: 8) {
|
||||
@@ -303,13 +303,13 @@ struct StepMatchScreen: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
WizardOutlinedField(label: "Punti per vincere un set", text: $pointsPerSetText)
|
||||
WizardOutlinedField(label: L10n.t("wizard.match.points.per.set.label"), 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)
|
||||
WizardOutlinedField(label: L10n.t("wizard.match.tiebreak.points.label"), text: $pointsDecidingSetText)
|
||||
.onChange(of: pointsDecidingSetText) { text in
|
||||
let filtered = String(text.filter(\.isNumber).prefix(2))
|
||||
if filtered != text { pointsDecidingSetText = filtered }
|
||||
@@ -321,17 +321,17 @@ struct StepMatchScreen: View {
|
||||
private func saveAndContinue() {
|
||||
let trimmedOpponent = opponent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedOpponent.isEmpty else {
|
||||
onError("Inserisci il nome avversario")
|
||||
onError(L10n.t("wizard.error.opponent.name.required"))
|
||||
return
|
||||
}
|
||||
|
||||
if customRules && ["volley", "racket"].contains(boardType) {
|
||||
guard let perSet = Int(pointsPerSetText), perSet >= 1 else {
|
||||
onError("Inserisci i punti per vincere un set")
|
||||
onError(L10n.t("wizard.error.points.per.set.required"))
|
||||
return
|
||||
}
|
||||
guard let deciding = Int(pointsDecidingSetText), deciding >= 1 else {
|
||||
onError("Inserisci i punti del tie-break")
|
||||
onError(L10n.t("wizard.error.tiebreak.points.required"))
|
||||
return
|
||||
}
|
||||
pointsPerSet = perSet
|
||||
@@ -340,11 +340,11 @@ struct StepMatchScreen: View {
|
||||
|
||||
if customRules && ["basket", "timed"].contains(boardType) {
|
||||
guard let periodMins = Int(periodDurationText), periodMins >= 1 else {
|
||||
onError("Inserisci la durata del periodo in minuti")
|
||||
onError(L10n.t("wizard.error.period.duration.required"))
|
||||
return
|
||||
}
|
||||
guard let overtimeMins = Int(overtimeDurationText), overtimeMins >= 1 else {
|
||||
onError("Inserisci la durata dei supplementari in minuti")
|
||||
onError(L10n.t("wizard.error.overtime.duration.required"))
|
||||
return
|
||||
}
|
||||
periodDurationMins = periodMins
|
||||
|
||||
@@ -45,35 +45,35 @@ struct StepNetworkTestScreen: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Test rete")
|
||||
Text(L10n.t("wizard.network.test.title"))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
Text("Verifica che la connessione regga l'upload della diretta.")
|
||||
Text(L10n.t("wizard.network.test.subtitle"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.top, 8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
MetricCard(
|
||||
label: "Download",
|
||||
label: L10n.t("wizard.network.download.label"),
|
||||
value: testing ? "..." : String(format: "%.1f Mbps", downloadMbps)
|
||||
)
|
||||
MetricCard(
|
||||
label: "Upload",
|
||||
label: L10n.t("wizard.network.upload.label"),
|
||||
value: testing ? "..." : String(format: "%.1f Mbps", uploadMbps),
|
||||
highlight: ready
|
||||
)
|
||||
MetricCard(
|
||||
label: "Latenza",
|
||||
label: L10n.t("wizard.network.latency.label"),
|
||||
value: testing ? "..." : "\(latencyMs) ms"
|
||||
)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
|
||||
MetricCard(label: "Tipo rete", value: networkType)
|
||||
MetricCard(label: L10n.t("wizard.network.type.label"), value: networkType)
|
||||
.padding(.top, 12)
|
||||
|
||||
if testCompleted && ready {
|
||||
Text("PRONTO PER ANDARE IN DIRETTA")
|
||||
Text(L10n.t("wizard.network.ready.label"))
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(MatchColors.successGreen)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -82,7 +82,7 @@ struct StepNetworkTestScreen: View {
|
||||
|
||||
if let quality = selectedQualityLabel {
|
||||
WizardReadOnlyField(
|
||||
label: "Qualità streaming (automatica)",
|
||||
label: L10n.t("wizard.network.quality.label"),
|
||||
value: quality
|
||||
)
|
||||
.padding(.top, 12)
|
||||
@@ -91,20 +91,20 @@ struct StepNetworkTestScreen: View {
|
||||
|
||||
if testCompleted, let shareUrl {
|
||||
WizardReadOnlyField(
|
||||
label: currentSession.platform == "youtube" ? "Link YouTube" : "Link diretta",
|
||||
label: currentSession.platform == "youtube" ? L10n.t("wizard.network.link.youtube.label") : L10n.t("wizard.network.link.live.label"),
|
||||
value: shareUrl
|
||||
)
|
||||
.padding(.top, 16)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
MatchSecondaryButton(label: "COPIA", action: { copyToClipboard(shareUrl) })
|
||||
MatchSecondaryButton(label: L10n.t("wizard.action.copy"), action: { copyToClipboard(shareUrl) })
|
||||
if let url = URL(string: shareUrl) {
|
||||
ShareLink(
|
||||
item: url,
|
||||
subject: Text("Diretta — \(match.teamName) vs \(match.opponentName)"),
|
||||
subject: Text(L10n.t("wizard.network.share.subject", match.teamName, match.opponentName)),
|
||||
message: Text(shareUrl)
|
||||
) {
|
||||
Text("CONDIVIDI")
|
||||
Text(L10n.t("wizard.action.share"))
|
||||
.font(MatchTypography.labelLarge)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
@@ -115,13 +115,13 @@ struct StepNetworkTestScreen: View {
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
MatchSecondaryButton(label: "CONDIVIDI LINK REGIA", action: shareRegiaLink)
|
||||
MatchSecondaryButton(label: L10n.t("wizard.action.share.regia.link"), action: shareRegiaLink)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
if !testCompleted {
|
||||
MatchSecondaryButton(
|
||||
label: testing ? "TEST IN CORSO..." : "AVVIA TEST RETE",
|
||||
label: testing ? L10n.t("wizard.network.test.running.label") : L10n.t("wizard.network.test.start.label"),
|
||||
action: runTest,
|
||||
enabled: !testing
|
||||
)
|
||||
@@ -133,10 +133,10 @@ struct StepNetworkTestScreen: View {
|
||||
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)
|
||||
MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack, enabled: !starting)
|
||||
.frame(width: backWidth)
|
||||
MatchPrimaryButton(
|
||||
label: "INIZIA >",
|
||||
label: L10n.t("wizard.action.start"),
|
||||
action: startLive,
|
||||
enabled: ready,
|
||||
loading: starting
|
||||
@@ -230,12 +230,12 @@ struct StepNetworkTestScreen: View {
|
||||
do {
|
||||
let url = try await container.sessionRepository.createRegiaLink(sessionId: currentSession.id)
|
||||
guard let link = URL(string: url) else {
|
||||
onError("Link regia non valido")
|
||||
onError(L10n.t("wizard.error.regia.link"))
|
||||
return
|
||||
}
|
||||
shareItem = ShareItem(
|
||||
items: [link],
|
||||
subject: "Link regia — \(match.teamName) vs \(match.opponentName)"
|
||||
subject: L10n.t("broadcast.share.regia.subject", "\(match.teamName) vs \(match.opponentName)")
|
||||
)
|
||||
} catch {
|
||||
if let message = UserFacingError.message(for: error) {
|
||||
|
||||
@@ -17,9 +17,9 @@ struct StepTransmissionScreen: View {
|
||||
}
|
||||
|
||||
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" }
|
||||
guard let team else { return L10n.t("wizard.transmission.youtube.activating") }
|
||||
if !team.canUseYoutube { return L10n.t("wizard.transmission.youtube.premium.required") }
|
||||
if !team.isYoutubeReady { return L10n.t("wizard.transmission.youtube.activating") }
|
||||
return team.youtubeDestinationLabel
|
||||
}
|
||||
|
||||
@@ -27,18 +27,18 @@ struct StepTransmissionScreen: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let plan = team?.planName {
|
||||
Text("Piano \(plan)")
|
||||
Text(L10n.t("wizard.transmission.plan.label", plan))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
|
||||
Text("Piattaforma")
|
||||
Text(L10n.t("wizard.transmission.platform.title"))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
|
||||
WizardPlatformCard(
|
||||
title: "Match Live TV",
|
||||
subtitle: "Diretta sul nostro sito (incluso)",
|
||||
subtitle: L10n.t("wizard.transmission.platform.site.subtitle"),
|
||||
selected: platform == "matchlivetv",
|
||||
onClick: { platform = "matchlivetv" }
|
||||
)
|
||||
@@ -49,24 +49,24 @@ struct StepTransmissionScreen: View {
|
||||
subtitle: youtubeSubtitle,
|
||||
selected: platform == "youtube",
|
||||
enabled: youtubeReady,
|
||||
badge: team?.canUseYoutube == false ? "Premium" : nil,
|
||||
badge: team?.canUseYoutube == false ? L10n.t("wizard.transmission.youtube.badge") : nil,
|
||||
onClick: selectYoutube
|
||||
)
|
||||
.padding(.top, 8)
|
||||
|
||||
Text("Visibilità")
|
||||
Text(L10n.t("wizard.transmission.visibility.title"))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.padding(.top, 24)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
WizardChoiceButton(
|
||||
label: "PUBBLICO",
|
||||
label: L10n.t("wizard.transmission.public.label"),
|
||||
selected: privacy == "public",
|
||||
action: { privacy = "public" }
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
WizardChoiceButton(
|
||||
label: "NON IN ELENCO",
|
||||
label: L10n.t("wizard.transmission.unlisted.label"),
|
||||
selected: privacy == "unlisted",
|
||||
action: { privacy = "unlisted" }
|
||||
)
|
||||
@@ -80,7 +80,7 @@ struct StepTransmissionScreen: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 8)
|
||||
|
||||
Text("La partita resta sempre visibile nel backend della squadra per tutta la durata dell'abbonamento.")
|
||||
Text(L10n.t("wizard.transmission.backend.note"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -88,7 +88,7 @@ struct StepTransmissionScreen: View {
|
||||
|
||||
WizardFooterButtons(
|
||||
onBack: onBack,
|
||||
forwardLabel: "AVANTI >",
|
||||
forwardLabel: L10n.t("wizard.action.next"),
|
||||
forwardLoading: creating,
|
||||
onForward: createSessionAndContinue
|
||||
)
|
||||
@@ -111,16 +111,16 @@ struct StepTransmissionScreen: View {
|
||||
|
||||
private var visibilityDescription: String {
|
||||
if privacy == "public" {
|
||||
return "Compare nell'elenco dirette su MatchLiveTV.it, nelle ricerche e sul canale YouTube (se selezionato)."
|
||||
return L10n.t("wizard.transmission.public.desc")
|
||||
}
|
||||
return "Non compare negli elenchi pubblici né nelle ricerche. Solo chi ha il link può guardare."
|
||||
return L10n.t("wizard.transmission.unlisted.desc")
|
||||
}
|
||||
|
||||
private func selectYoutube() {
|
||||
if youtubeReady {
|
||||
platform = "youtube"
|
||||
} else {
|
||||
onError("YouTube non disponibile per questa squadra")
|
||||
onError(L10n.t("wizard.error.youtube.unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,18 +34,18 @@ struct TeamBrandingRow: View {
|
||||
if isConfigured {
|
||||
TeamLogoImage(remoteLogoUrl: remoteLogoUrl, localLogoImage: localLogoImage, size: 44)
|
||||
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 36)
|
||||
Text(teamName.isEmpty ? "Squadra" : teamName)
|
||||
Text(teamName.isEmpty ? L10n.t("wizard.branding.team.fallback.name") : teamName)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
ColorAccentBar(color: ColorHex.swiftUIColor(displayColorHex), height: 32)
|
||||
if nameEditable {
|
||||
TextField("Nome avversario", text: $teamName)
|
||||
TextField(L10n.t("wizard.branding.opponent.name.placeholder"), text: $teamName)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.padding(8)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||
} else {
|
||||
Text(teamName.isEmpty ? "Squadra" : teamName)
|
||||
Text(teamName.isEmpty ? L10n.t("wizard.branding.team.fallback.name") : teamName)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.lineLimit(1)
|
||||
}
|
||||
@@ -57,6 +57,7 @@ struct TeamBrandingRow: View {
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(L10n.t("wizard.branding.edit.team.cd"))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
@@ -100,29 +101,29 @@ private struct TeamBrandingCustomizeSheet: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Personalizza").font(MatchTypography.headlineMedium)
|
||||
Text(L10n.t("wizard.branding.customize.title")).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)
|
||||
Text(L10n.t("wizard.branding.team.name.label")).font(MatchTypography.labelLarge).padding(.top, 20)
|
||||
TextField(L10n.t("wizard.branding.opponent.name.placeholder"), 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(L10n.t("wizard.branding.team.name.label")).font(MatchTypography.labelLarge).padding(.top, 20)
|
||||
Text(teamName).font(MatchTypography.titleMedium).padding(.top, 4)
|
||||
}
|
||||
|
||||
Text("Logo").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||
Text(L10n.t("wizard.branding.logo.label")).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")
|
||||
Text(L10n.t("wizard.branding.upload.logo"))
|
||||
.font(MatchTypography.labelLarge)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
@@ -130,7 +131,7 @@ private struct TeamBrandingCustomizeSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if hasLogo {
|
||||
MatchSecondaryButton(label: "RIMUOVI", action: {
|
||||
MatchSecondaryButton(label: L10n.t("wizard.branding.remove.logo"), action: {
|
||||
localLogoImage = nil
|
||||
logoPickerItem = nil
|
||||
})
|
||||
@@ -139,13 +140,13 @@ private struct TeamBrandingCustomizeSheet: View {
|
||||
}
|
||||
.padding(.top, 10)
|
||||
|
||||
Text("Colore squadra").font(MatchTypography.labelLarge).padding(.top, 20)
|
||||
Text(L10n.t("wizard.branding.color.label")).font(MatchTypography.labelLarge).padding(.top, 20)
|
||||
TeamColorPickerPanel(initialColorHex: draftColor.isEmpty ? fallbackColorHex : draftColor) { draftColor = $0 }
|
||||
.padding(.top, 12)
|
||||
|
||||
MatchPrimaryButton(label: "SALVA", action: save)
|
||||
MatchPrimaryButton(label: L10n.t("wizard.branding.save"), action: save)
|
||||
.padding(.top, 24)
|
||||
MatchSecondaryButton(label: "ANNULLA", action: onDismiss)
|
||||
MatchSecondaryButton(label: L10n.t("wizard.branding.cancel"), action: onDismiss)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
@@ -205,7 +206,7 @@ private struct TeamLogoImage: View {
|
||||
private var placeholder: some View {
|
||||
ZStack {
|
||||
MatchColors.surface
|
||||
Text("Nessun logo")
|
||||
Text(L10n.t("wizard.branding.no.logo"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ struct TeamColorPickerPanel: View {
|
||||
.overlay(Circle().stroke(MatchColors.outline, lineWidth: 2))
|
||||
saturationBrightnessPicker
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Tonalità")
|
||||
Text(L10n.t("wizard.color.hue"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
hueGradient
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
private let wizardStepTitles = ["01 · Partita", "02 · Trasmissione", "03 · Test rete"]
|
||||
|
||||
func wizardStepTitle(_ step: Int) -> String {
|
||||
wizardStepTitles[min(max(step, 1), 3) - 1]
|
||||
let titles = [
|
||||
L10n.t("wizard.step.title.match"),
|
||||
L10n.t("wizard.step.title.transmission"),
|
||||
L10n.t("wizard.step.title.network")
|
||||
]
|
||||
return titles[min(max(step, 1), 3) - 1]
|
||||
}
|
||||
|
||||
struct WizardStepIndicator: View {
|
||||
@@ -32,7 +35,7 @@ struct WizardFooterButtons: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
if showBack {
|
||||
MatchSecondaryButton(label: "Indietro", action: onBack)
|
||||
MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading)
|
||||
|
||||
@@ -11,6 +11,7 @@ struct WizardShellScreen: View {
|
||||
@State private var team: Team?
|
||||
@State private var currentStep: Int
|
||||
@State private var error: String?
|
||||
@State private var languageTick = 0
|
||||
|
||||
init(container: AppContainer, matchId: String, step: Int, onClose: @escaping () -> Void, onStartLive: @escaping (String) -> Void) {
|
||||
self.container = container
|
||||
@@ -28,6 +29,7 @@ struct WizardShellScreen: View {
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark").foregroundStyle(.white)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("common.close"))
|
||||
Text(wizardStepTitle(currentStep))
|
||||
.font(MatchTypography.titleMedium)
|
||||
Spacer(minLength: 0)
|
||||
@@ -70,7 +72,7 @@ struct WizardShellScreen: View {
|
||||
onError: { presentError($0) }
|
||||
)
|
||||
} else {
|
||||
Text("Completa lo step Trasmissione")
|
||||
Text(L10n.t("wizard.complete.transmission.step"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -99,11 +101,15 @@ struct WizardShellScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Errore", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button("OK", role: .cancel) {}
|
||||
.alert(L10n.t("common.error.generic").capitalized, isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
|
||||
Button(L10n.t("action.ok"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||
languageTick += 1
|
||||
}
|
||||
.id(languageTick)
|
||||
}
|
||||
|
||||
private func presentError(_ message: String) {
|
||||
|
||||
Reference in New Issue
Block a user