Files
MatchLiveTv/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift
T
eminuxandCursor 24c6ce4da0 Lascia trasmissibili le partite anche dopo l'orario previsto.
Se l'operatore è in ritardo deve ancora trovare la gara nell'app: restano in hub per tutta la giornata e, nei tornei aperti, fino alla fine dell'evento.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:07:23 +02:00

757 lines
29 KiB
Swift

import SwiftUI
struct MatchesScreen: View {
@ObservedObject var container: AppContainer
var refreshToken: Int = 0
let onOpenSetup: (String) -> Void
let onOpenBroadcast: (String) -> Void
let onOpenAccount: () -> 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 configureMatch: Match?
@State private var pendingConfigureMatch: Match?
@State private var deleteMatch: Match?
@State private var snackbar: String?
@State private var showLanguagePicker = false
@State private var languageTick = 0
@State private var announcements: [AnnouncementDto] = []
var body: some View {
MatchScreenScaffold(
topBar: {
HStack {
MatchLiveWordmark(compact: true)
Spacer()
Button {
showLanguagePicker = true
} label: {
Image(systemName: "globe")
.foregroundStyle(MatchColors.textSecondary)
.frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel(L10n.t("language.label"))
.accessibilityIdentifier("matches.language")
Button {
onOpenAccount()
} label: {
Image(systemName: "person.crop.circle")
.foregroundStyle(MatchColors.textSecondary)
.frame(minWidth: 44, minHeight: 44)
}
.accessibilityLabel(L10n.t("account.title"))
.accessibilityIdentifier("matches.account")
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
},
content: {
VStack(spacing: 0) {
if !announcements.isEmpty {
AnnouncementBanners(
announcements: announcements,
onDismiss: { id in
container.announcementStore.dismiss(id)
announcements.removeAll { $0.id == id }
}
)
.padding(.horizontal, 20)
.padding(.top, 8)
}
Group {
if loading {
ProgressView().tint(MatchColors.primaryRed)
} else if teams.isEmpty {
VStack(spacing: 16) {
Text(L10n.t("matches.no.team.title"))
.foregroundStyle(MatchColors.textSecondary)
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: L10n.t("matches.retry").uppercased(), action: { reload(showSpinner: false) })
}
.padding(24)
} else {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 16) {
Text(L10n.t("matches.hello", container.tokenStore.session?.user.name ?? ""))
.font(MatchTypography.headlineMedium)
Text(L10n.t("matches.subtitle"))
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true })
.accessibilityIdentifier("matches.new")
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(L10n.t("matches.no.other"))
.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(
L10n.t("sheet.configure.now.title"),
isPresented: Binding(get: { configureMatch != nil }, set: { if !$0 { configureMatch = nil } })
) {
Button(L10n.t("sheet.configure")) {
if let match = configureMatch { onOpenSetup(match.id) }
configureMatch = nil
}
Button(L10n.t("sheet.later"), role: .cancel) {
configureMatch = nil
}
} message: {
Text(L10n.t("sheet.configure.now.body"))
}
.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 {
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(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(
onSchedule: {
showNewMatch = false
showSchedule = true
},
onQuickStart: {
showNewMatch = false
createQuickMatch()
}
)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showSchedule, onDismiss: {
if let match = pendingConfigureMatch {
pendingConfigureMatch = nil
configureMatch = match
}
}) {
ScheduleMatchSheet(container: container, teamId: activeTeam?.id) { match in
pendingConfigureMatch = match
snackbar = L10n.t("matches.msg.scheduled.ok")
reload(showSpinner: false)
showSchedule = false
} onFailed: { error in
snackbar = UserFacingError.message(for: error) ?? L10n.t("matches.msg.schedule.error")
}
}
.sheet(isPresented: $showTeamPicker) {
TeamPickerSheet(teams: teams, activeTeamId: activeTeam?.id) { team in
container.matchRepository.selectTeam(teamId: team.id)
activeTeam = team
reload(showSpinner: false)
showTeamPicker = false
}
}
.sheet(isPresented: $showLanguagePicker) {
LanguagePickerView()
}
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
languageTick += 1
}
.id(languageTick)
.overlay {
if actionLoading {
Color.black.opacity(0.35)
.ignoresSafeArea()
ProgressView().tint(MatchColors.primaryRed)
}
}
.overlay {
if let match = resumeMatch {
ZStack(alignment: .bottom) {
Color.black.opacity(0.4)
.ignoresSafeArea()
.onTapGesture { resumeMatch = nil }
ResumeSessionSheet(
match: match,
onResumeCamera: {
resumeMatch = nil
resumeBroadcast(match)
},
onContinueSetup: {
resumeMatch = nil
onOpenSetup(match.id)
},
onDismiss: { resumeMatch = nil }
)
}
.ignoresSafeArea(edges: .bottom)
.accessibilityIdentifier("resume.sheet")
}
}
.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 && !($0.scheduledAt ?? "").isEmpty }
}
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 L10n.t("matches.empty.title")
}
if !scheduledMatches.isEmpty {
return L10n.t("matches.scheduled.title")
}
return L10n.t("matches.ready.title")
}
private var emptyCalendarMessage: String {
var message = L10n.t("matches.empty.hint")
if let teamName = activeTeam?.name {
message += "\n\n" + L10n.t("matches.active.team", teamName)
}
if teams.count > 1 {
message += "\n" + L10n.t("matches.multi.team.hint")
}
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) ?? L10n.t("matches.msg.load.error")
}
announcements = await container.visibleAnnouncements()
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) ?? L10n.t("matches.msg.resume.failed")
}
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) ?? L10n.t("matches.msg.create.failed")
}
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(L10n.t("sheet.resume.banner"))
.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)
.accessibilityIdentifier("matches.resume.banner")
}
}
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)
}
}
struct AnnouncementBanners: View {
let announcements: [AnnouncementDto]
let onDismiss: (String) -> Void
var body: some View {
VStack(spacing: 8) {
ForEach(announcements) { item in
AnnouncementBanner(item: item, onDismiss: { onDismiss(item.id) })
}
}
}
}
private struct AnnouncementBanner: View {
let item: AnnouncementDto
let onDismiss: () -> Void
@Environment(\.openURL) private var openURL
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: iconName)
.foregroundStyle(accent)
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(MatchTypography.titleMedium)
.foregroundStyle(accent)
Text(item.body)
.font(MatchTypography.bodyMedium)
}
Spacer(minLength: 8)
if item.dismissible {
Button(action: onDismiss) {
Image(systemName: "xmark")
.foregroundStyle(MatchColors.textSecondary)
}
.buttonStyle(.plain)
.accessibilityLabel(L10n.t("announcement.dismiss"))
}
}
if let urlString = item.actionUrl, let url = URL(string: urlString), !urlString.isEmpty {
Button(item.actionLabel?.isEmpty == false ? item.actionLabel! : L10n.t("announcement.open")) {
openURL(url)
}
.font(MatchTypography.labelLarge)
.foregroundStyle(accent)
}
}
.padding(14)
.background(accent.opacity(0.16), in: RoundedRectangle(cornerRadius: 12))
}
private var accent: Color {
if item.severity == "critical" || item.kind == "maintenance" {
return MatchColors.accentYellow
}
if item.kind == "update" {
return MatchColors.successGreen
}
return MatchColors.primaryRed
}
private var iconName: String {
switch item.kind {
case "maintenance": return "wrench.and.screwdriver.fill"
case "update": return "arrow.down.app.fill"
default: return "megaphone.fill"
}
}
}
private struct NewMatchSheet: View {
let onSchedule: () -> Void
let onQuickStart: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(L10n.t("sheet.new.match.title"))
.font(MatchTypography.headlineMedium)
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: L10n.t("sheet.schedule.option"),
subtitle: L10n.t("sheet.schedule.option.sub"),
action: onSchedule
)
.accessibilityIdentifier("sheet.schedule.option")
SheetOptionTile(
systemImage: "play.circle",
title: L10n.t("sheet.quick.option"),
subtitle: L10n.t("sheet.quick.option.sub"),
action: onQuickStart
)
.accessibilityIdentifier("sheet.quick.option")
}
.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 ResumeSessionSheet: View {
let match: Match
let onResumeCamera: () -> Void
let onContinueSetup: () -> Void
let onDismiss: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(L10n.t("sheet.live.in.progress"))
.font(MatchTypography.headlineMedium)
.accessibilityIdentifier("resume.sheet.title")
Text("\(match.teamName) vs \(match.opponentName)")
.font(MatchTypography.bodyMedium)
.padding(.top, 4)
if let status = match.activeSessionStatus, !status.isEmpty {
Text(L10n.t("sheet.status", status))
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.primaryRed)
.padding(.top, 8)
}
VStack(spacing: 8) {
if match.canResumeCamera {
MatchPrimaryButton(label: L10n.t("sheet.resume.camera"), action: onResumeCamera)
.accessibilityIdentifier("resume.camera")
}
if match.activeSessionStatus == "idle" {
MatchSecondaryButton(label: L10n.t("sheet.continue.setup"), action: onContinueSetup)
.accessibilityIdentifier("resume.continue.setup")
}
Button(action: onDismiss) {
Text(L10n.t("action.cancel"))
.font(MatchTypography.labelLarge)
.foregroundStyle(MatchColors.textSecondary)
.frame(maxWidth: .infinity)
.frame(height: 44)
}
.buttonStyle(.plain)
.accessibilityIdentifier("resume.cancel")
}
.padding(.top, 20)
}
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 24)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(MatchColors.surface)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("resume.sheet.panel")
}
}
private struct ScheduleMatchSheet: View {
@ObservedObject var container: AppContainer
let teamId: String?
let onCreated: (Match) -> Void
var onFailed: (Error) -> Void = { _ in }
@Environment(\.dismiss) private var dismiss
@State private var opponent = ""
@State private var location = ""
@State private var date = Date().addingTimeInterval(86400)
@State private var saving = false
var body: some View {
NavigationStack {
Form {
TextField(L10n.t("sheet.opponent"), text: $opponent)
TextField(L10n.t("sheet.location.optional"), text: $location)
DatePicker(L10n.t("sheet.date.time"), selection: $date)
}
.navigationTitle(L10n.t("sheet.schedule.title"))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.close")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(L10n.t("sheet.save.schedule")) { create() }
.disabled(opponent.isEmpty || teamId == nil || saving)
}
}
}
}
private func create() {
guard let teamId, !saving else { return }
saving = true
Task {
do {
let match = try await container.matchRepository.createScheduledMatch(
teamId: teamId,
opponentName: opponent,
scheduledAt: date,
location: location.nilIfEmpty
)
onCreated(match)
} catch {
onFailed(error)
saving = false
}
}
}
}
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(L10n.t("sheet.choose.team"))
}
}
}
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 }
}