Aggiunge app iOS nativa con parità multi-sport e test unitari.
Porting SwiftUI da Android (auth, hub, wizard, broadcast RTMP, overlay, scoring board-aware), reload hub al ritorno da diretta, decodifica score tollerante e documentazione allineata. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
b798e0ea06
commit
585332e32e
@@ -0,0 +1,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 }
|
||||
}
|
||||
Reference in New Issue
Block a user