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>
757 lines
29 KiB
Swift
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 }
|
|
}
|