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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Emiliano Frascaro
2026-06-14 21:37:32 +02:00
co-authored by Cursor
parent b798e0ea06
commit 585332e32e
101 changed files with 7910 additions and 5 deletions
@@ -0,0 +1,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 }
}