diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 534edbf..0635e0e 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -87,6 +87,9 @@ FF17CA4D2CB9243E003C7323 /* FollowUpMatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA4C2CB9243E003C7323 /* FollowUpMatchView.swift */; }; FF17CA4E2CB9243E003C7323 /* FollowUpMatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA4C2CB9243E003C7323 /* FollowUpMatchView.swift */; }; FF17CA4F2CB9243E003C7323 /* FollowUpMatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA4C2CB9243E003C7323 /* FollowUpMatchView.swift */; }; + FF17CA532CBE4788003C7323 /* BracketCallingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA522CBE4788003C7323 /* BracketCallingView.swift */; }; + FF17CA542CBE4788003C7323 /* BracketCallingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA522CBE4788003C7323 /* BracketCallingView.swift */; }; + FF17CA552CBE4788003C7323 /* BracketCallingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA522CBE4788003C7323 /* BracketCallingView.swift */; }; FF1CBC1B2BB53D1F0036DAAB /* FederalTournament.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1CBC182BB53D1F0036DAAB /* FederalTournament.swift */; }; FF1CBC1D2BB53DC10036DAAB /* Calendar+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1CBC1C2BB53DC10036DAAB /* Calendar+Extensions.swift */; }; FF1CBC1F2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1CBC1E2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift */; }; @@ -985,6 +988,7 @@ FF11628B2BD05267000C4809 /* LoserRoundStepScheduleEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoserRoundStepScheduleEditorView.swift; sourceTree = ""; }; FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiCourtPickerView.swift; sourceTree = ""; }; FF17CA4C2CB9243E003C7323 /* FollowUpMatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowUpMatchView.swift; sourceTree = ""; }; + FF17CA522CBE4788003C7323 /* BracketCallingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BracketCallingView.swift; sourceTree = ""; }; FF1CBC182BB53D1F0036DAAB /* FederalTournament.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FederalTournament.swift; sourceTree = ""; }; FF1CBC1C2BB53DC10036DAAB /* Calendar+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Calendar+Extensions.swift"; sourceTree = ""; }; FF1CBC1E2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FederalTournamentSearchScope.swift; sourceTree = ""; }; @@ -1772,6 +1776,7 @@ FF9268082BCEDC2C0080F940 /* CallView.swift */, FF1162792BCF8109000C4809 /* CallMessageCustomizationView.swift */, FF2EFBEF2BDE295E0049CE3B /* SendToAllView.swift */, + FF17CA522CBE4788003C7323 /* BracketCallingView.swift */, FFEF7F4C2BDE68F80033D0F0 /* Components */, ); path = Calling; @@ -2426,6 +2431,7 @@ FF5DA18F2BB9268800A33061 /* GroupStagesSettingsView.swift in Sources */, FF663FBE2BE019EC0031AE83 /* TournamentFilterView.swift in Sources */, FF1F4B752BFA00FC000B4573 /* HtmlGenerator.swift in Sources */, + FF17CA532CBE4788003C7323 /* BracketCallingView.swift in Sources */, FF8F26382BAD523300650388 /* PadelRule.swift in Sources */, FF967CF42BAECC0B00A9A3BD /* TeamRegistration.swift in Sources */, FFF8ACDB2B923F48008466FA /* Date+Extensions.swift in Sources */, @@ -2697,6 +2703,7 @@ FF4CC0022C996C0600151637 /* GroupStagesSettingsView.swift in Sources */, FF4CC0032C996C0600151637 /* TournamentFilterView.swift in Sources */, FF4CC0042C996C0600151637 /* HtmlGenerator.swift in Sources */, + FF17CA542CBE4788003C7323 /* BracketCallingView.swift in Sources */, FF4CC0052C996C0600151637 /* PadelRule.swift in Sources */, FF4CC0062C996C0600151637 /* TeamRegistration.swift in Sources */, FF4CC0072C996C0600151637 /* Date+Extensions.swift in Sources */, @@ -2947,6 +2954,7 @@ FF70FB812C90584900129CC2 /* GroupStagesSettingsView.swift in Sources */, FF70FB822C90584900129CC2 /* TournamentFilterView.swift in Sources */, FF70FB832C90584900129CC2 /* HtmlGenerator.swift in Sources */, + FF17CA552CBE4788003C7323 /* BracketCallingView.swift in Sources */, FF70FB842C90584900129CC2 /* PadelRule.swift in Sources */, FF70FB852C90584900129CC2 /* TeamRegistration.swift in Sources */, FF70FB862C90584900129CC2 /* Date+Extensions.swift in Sources */, diff --git a/PadelClub/Views/Calling/BracketCallingView.swift b/PadelClub/Views/Calling/BracketCallingView.swift new file mode 100644 index 0000000..2650d8f --- /dev/null +++ b/PadelClub/Views/Calling/BracketCallingView.swift @@ -0,0 +1,152 @@ +// +// BracketCallingView.swift +// PadelClub +// +// Created by razmig on 15/10/2024. +// + +import SwiftUI + +struct BracketCallingView: View { + @Environment(Tournament.self) var tournament: Tournament + @State private var displayByMatch: Bool = true + @State private var initialSeedRound: Int = 0 + @State private var initialSeedCount: Int = 0 + let tournamentRounds: [Round] + let teams: [TeamRegistration] + + init(tournament: Tournament) { + let rounds = tournament.rounds() + self.tournamentRounds = rounds + self.teams = tournament.availableSeeds() + let index = rounds.count - 1 + _initialSeedRound = .init(wrappedValue: index) + _initialSeedCount = .init(wrappedValue: RoundRule.numberOfMatches(forRoundIndex: index)) + } + + var initialRound: Round { + tournamentRounds.first(where: { $0.index == initialSeedRound })! + } + + func filteredRounds() -> [Round] { + tournamentRounds.filter({ $0.index >= initialSeedRound }).reversed() + } + + func seedCount(forRoundIndex roundIndex: Int) -> Int { + if roundIndex < initialSeedRound { return 0 } + if roundIndex == initialSeedRound { + return initialSeedCount + } + + let seedCount = RoundRule.numberOfMatches(forRoundIndex: roundIndex) + let previousSeedCount = self.seedCount(forRoundIndex: roundIndex - 1) + + let total = seedCount - previousSeedCount + if total < 0 { return 0 } + return total + } + + func seeds(forRoundIndex roundIndex: Int) -> [TeamRegistration] { + let previousSeeds: Int = (initialSeedRound.. some View { + List { + NavigationLink("Équipes non contactées") { + TeamsCallingView(teams: seeds.filter({ $0.callDate == nil })) + } + Section { + ForEach(seeds) { team in + CallView.TeamView(team: team) + } + } header: { + Text(round.roundTitle()) + } + } + .overlay { + if seeds.isEmpty { + ContentUnavailableView { + Label("Aucune équipe dans ce tour", systemImage: "clock.badge.questionmark") + } description: { + Text("Padel Club n'a pas réussi à déterminer quelles équipes jouent ce tour.") + } actions: { +// RowButtonView("Horaire intelligent") { +// selectedScheduleDestination = nil +// } + } + } + } + .headerProminence(.increased) + .navigationTitle(round.roundTitle()) + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + } +} + +//#Preview { +// SeedsCallingView() +//} diff --git a/PadelClub/Views/Planning/PlanningView.swift b/PadelClub/Views/Planning/PlanningView.swift index 4a06f82..f677510 100644 --- a/PadelClub/Views/Planning/PlanningView.swift +++ b/PadelClub/Views/Planning/PlanningView.swift @@ -30,11 +30,11 @@ struct PlanningView: View { Dictionary(grouping: matches) { $0.startDate ?? .distantFuture } } - var days: [Date] { + func days(timeSlots: [Date:[Match]]) -> [Date] { Set(timeSlots.keys.map { $0.startOfDay }).sorted() } - var keys: [Date] { + func keys(timeSlots: [Date:[Match]]) -> [Date] { timeSlots.keys.sorted() } @@ -54,7 +54,7 @@ struct PlanningView: View { } } - private func _computedTitle() -> String { + private func _computedTitle(days: [Date]) -> String { if let selectedDay { return selectedDay.formatted(.dateTime.day().weekday().month()) } else { @@ -67,143 +67,160 @@ struct PlanningView: View { } var body: some View { - List { - _bySlotView() - } - .navigationTitle(Text(_computedTitle())) - .toolbar(content: { - if days.count > 1 { - ToolbarTitleMenu { - Picker(selection: $selectedDay) { - Text("Tous les jours").tag(nil as Date?) - ForEach(days, id: \.self) { day in - Text(day.formatted(.dateTime.day().weekday().month())).tag(day as Date?) + let timeSlots = self.timeSlots + let keys = self.keys(timeSlots: timeSlots) + let days = self.days(timeSlots: timeSlots) + let matches = matches + BySlotView(days: days, keys: keys, timeSlots: timeSlots, matches: matches, selectedDay: selectedDay, filterOption: filterOption, showFinishedMatches: showFinishedMatches) + .navigationTitle(Text(_computedTitle(days: days))) + .toolbar(content: { + if days.count > 1 { + ToolbarTitleMenu { + Picker(selection: $selectedDay) { + Text("Tous les jours").tag(nil as Date?) + ForEach(days, id: \.self) { day in + Text(day.formatted(.dateTime.day().weekday().month())).tag(day as Date?) + } + } label: { + Text("Jour") } - } label: { - Text("Jour") + .pickerStyle(.automatic) } - .pickerStyle(.automatic) } - } - ToolbarItemGroup(placement: .topBarTrailing) { - Menu { - Picker(selection: $showFinishedMatches) { - Text("Afficher tous les matchs").tag(true) - Text("Masquer les matchs terminés").tag(false) + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + Picker(selection: $showFinishedMatches) { + Text("Afficher tous les matchs").tag(true) + Text("Masquer les matchs terminés").tag(false) + } label: { + Text("Option de filtrage") + } + .labelsHidden() + .pickerStyle(.inline) } label: { - Text("Option de filtrage") + Label("Filtrer", systemImage: "clock.badge.checkmark") + .symbolVariant(showFinishedMatches ? .fill : .none) } - .labelsHidden() - .pickerStyle(.inline) - } label: { - Label("Filtrer", systemImage: "clock.badge.checkmark") - .symbolVariant(showFinishedMatches ? .fill : .none) - } - Menu { - Picker(selection: $filterOption) { - ForEach(PlanningFilterOption.allCases) { - Text($0.localizedPlanningLabel()).tag($0) + Menu { + Picker(selection: $filterOption) { + ForEach(PlanningFilterOption.allCases) { + Text($0.localizedPlanningLabel()).tag($0) + } + } label: { + Text("Option de triage") } + .labelsHidden() + .pickerStyle(.inline) } label: { - Text("Option de triage") + Label("Trier", systemImage: "line.3.horizontal.decrease.circle") + .symbolVariant(filterOption == .byCourt ? .fill : .none) } - .labelsHidden() - .pickerStyle(.inline) - } label: { - Label("Trier", systemImage: "line.3.horizontal.decrease.circle") - .symbolVariant(filterOption == .byCourt ? .fill : .none) - } - } - }) - .overlay { - if matches.allSatisfy({ $0.startDate == nil }) { - ContentUnavailableView { - Label("Aucun horaire défini", systemImage: "clock.badge.questionmark") - } description: { - Text("Vous n'avez pas encore défini d'horaire pour les différentes phases du tournoi") - } actions: { - RowButtonView("Horaire intelligent") { - selectedScheduleDestination = nil + } + }) + .overlay { + if matches.allSatisfy({ $0.startDate == nil }) { + ContentUnavailableView { + Label("Aucun horaire défini", systemImage: "clock.badge.questionmark") + } description: { + Text("Vous n'avez pas encore défini d'horaire pour les différentes phases du tournoi") + } actions: { + RowButtonView("Horaire intelligent") { + selectedScheduleDestination = nil + } } } } - } } - @ViewBuilder - func _bySlotView() -> some View { - if matches.allSatisfy({ $0.startDate == nil }) == false { - ForEach(days.filter({ selectedDay == nil || selectedDay == $0 }), id: \.self) { day in - Section { - ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in - if let _matches = timeSlots[key] { - DisclosureGroup { - ForEach(_matches.sorted(by: filterOption == .byDefault ? \.computedOrder : \.courtIndexForSorting)) { match in - NavigationLink { - MatchDetailView(match: match, matchViewStyle: .sectionedStandardStyle) - } label: { - LabeledContent { - if let courtName = match.courtName() { - Text(courtName) - } - } label: { - if let groupStage = match.groupStageObject { - Text(groupStage.groupStageTitle(.title)) - } else if let round = match.roundObject { - Text(round.roundTitle()) + struct BySlotView: View { + @Environment(Tournament.self) var tournament: Tournament + let days: [Date] + let keys: [Date] + let timeSlots: [Date:[Match]] + let matches: [Match] + let selectedDay: Date? + let filterOption: PlanningFilterOption + let showFinishedMatches: Bool + + var body: some View { + List { + if matches.allSatisfy({ $0.startDate == nil }) == false { + ForEach(days.filter({ selectedDay == nil || selectedDay == $0 }), id: \.self) { day in + Section { + ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in + if let _matches = timeSlots[key]?.sorted(by: filterOption == .byDefault ? \.computedOrder : \.courtIndexForSorting) { + DisclosureGroup { + ForEach(_matches) { match in + NavigationLink { + MatchDetailView(match: match, matchViewStyle: .sectionedStandardStyle) + } label: { + LabeledContent { + if let courtName = match.courtName() { + Text(courtName) + } + } label: { + if let groupStage = match.groupStageObject { + Text(groupStage.groupStageTitle(.title)) + } else if let round = match.roundObject { + Text(round.roundTitle()) + } + Text(match.matchTitle()) + } } - Text(match.matchTitle()) } + } label: { + _timeSlotView(key: key, matches: _matches) } } - } label: { - _timeSlotView(key: key, matches: _matches) + } + } header: { + HStack { + Text(day.formatted(.dateTime.day().weekday().month())) + Spacer() + let count = _matchesCount(inDayInt: day.dayInt, timeSlots: timeSlots) + if showFinishedMatches { + Text(self._formattedMatchCount(count)) + } else { + Text(self._formattedMatchCount(count) + " restant\(count.pluralSuffix)") + } } } - } - } header: { - HStack { - Text(day.formatted(.dateTime.day().weekday().month())) - Spacer() - let count = _matchesCount(inDayInt: day.dayInt) - if showFinishedMatches { - Text(self._formattedMatchCount(count)) - } else { - Text(self._formattedMatchCount(count) + " restant\(count.pluralSuffix)") - } + .headerProminence(.increased) } } - .headerProminence(.increased) } } - } - - private func _matchesCount(inDayInt dayInt: Int) -> Int { - timeSlots.filter { $0.key.dayInt == dayInt }.flatMap({ $0.value }).count - } - private func _timeSlotView(key: Date, matches: [Match]) -> some View { - LabeledContent { - Text(self._formattedMatchCount(matches.count)) - } label: { - Text(key.formatted(date: .omitted, time: .shortened)).font(.title).fontWeight(.semibold) - let names = matches.sorted(by: \.computedOrder) - .compactMap({ $0.roundTitle() }) - .reduce(into: [String]()) { uniqueNames, name in - if !uniqueNames.contains(name) { - uniqueNames.append(name) - } + private func _matchesCount(inDayInt dayInt: Int, timeSlots: [Date:[Match]]) -> Int { + timeSlots.filter { $0.key.dayInt == dayInt }.flatMap({ $0.value }).count + } + + private func _timeSlotView(key: Date, matches: [Match]) -> some View { + LabeledContent { + Text(self._formattedMatchCount(matches.count)) + } label: { + Text(key.formatted(date: .omitted, time: .shortened)).font(.title).fontWeight(.semibold) + if matches.count <= tournament.courtCount { + let names = matches.sorted(by: \.computedOrder) + .compactMap({ $0.roundTitle() }) + .reduce(into: [String]()) { uniqueNames, name in + if !uniqueNames.contains(name) { + uniqueNames.append(name) + } + } + Text(names.joined(separator: ", ")) + } else { + Text(matches.count.formatted().appending(" matchs")) } - Text(names.joined(separator: ", ")) + } + } + + fileprivate func _formattedMatchCount(_ count: Int) -> String { + return "\(count.formatted()) match\(count.pluralSuffix)" } } - - fileprivate func _formattedMatchCount(_ count: Int) -> String { - return "\(count.formatted()) match\(count.pluralSuffix)" - } - } //#Preview { diff --git a/PadelClub/Views/Tournament/Screen/TournamentCallView.swift b/PadelClub/Views/Tournament/Screen/TournamentCallView.swift index 8b93bda..422d16e 100644 --- a/PadelClub/Views/Tournament/Screen/TournamentCallView.swift +++ b/PadelClub/Views/Tournament/Screen/TournamentCallView.swift @@ -16,6 +16,7 @@ enum CallDestination: Identifiable, Selectable, Equatable { case teams(Tournament) case seeds(Tournament) case groupStages(Tournament) + case brackets(Tournament) var id: String { switch self { @@ -25,6 +26,8 @@ enum CallDestination: Identifiable, Selectable, Equatable { return "seed" case .groupStages: return "groupStage" + case .brackets: + return "bracket" } } @@ -36,6 +39,8 @@ enum CallDestination: Identifiable, Selectable, Equatable { return "Têtes de série" case .groupStages: return "Poules" + case .brackets: + return "Tableau" } } @@ -47,6 +52,9 @@ enum CallDestination: Identifiable, Selectable, Equatable { case .seeds(let tournament): let allSeedCalled = tournament.seeds().filter({ tournament.isStartDateIsDifferentThanCallDate($0) || $0.callDate == nil }) return allSeedCalled.count + case .brackets(let tournament): + let availableSeeds = tournament.availableSeeds().filter({ tournament.isStartDateIsDifferentThanCallDate($0) || $0.callDate == nil }) + return availableSeeds.count case .groupStages(let tournament): let allSeedCalled = tournament.groupStageTeams().filter({ tournament.isStartDateIsDifferentThanCallDate($0) || $0.callDate == nil }) return allSeedCalled.count @@ -65,6 +73,9 @@ enum CallDestination: Identifiable, Selectable, Equatable { case .seeds(let tournament): let allSeedCalled = tournament.seeds().allSatisfy({ tournament.isStartDateIsDifferentThanCallDate($0) == false }) return allSeedCalled ? .checkmark : nil + case .brackets(let tournament): + let availableSeeds = tournament.availableSeeds().allSatisfy({ tournament.isStartDateIsDifferentThanCallDate($0) == false }) + return availableSeeds ? .checkmark : nil case .groupStages(let tournament): let allSeedCalled = tournament.groupStageTeams().allSatisfy({ tournament.isStartDateIsDifferentThanCallDate($0) == false }) return allSeedCalled ? .checkmark : nil @@ -83,16 +94,23 @@ struct TournamentCallView: View { self.tournament = tournament var destinations = [CallDestination]() let groupStageTeams = tournament.groupStageTeams() + let seededTeams = tournament.seededTeams() if groupStageTeams.isEmpty == false { destinations.append(.groupStages(tournament)) self._selectedDestination = State(wrappedValue: .groupStages(tournament)) } - if tournament.seededTeams().isEmpty == false { + if seededTeams.isEmpty == false { destinations.append(.seeds(tournament)) if groupStageTeams.isEmpty { self._selectedDestination = State(wrappedValue: .seeds(tournament)) } } + if tournament.availableSeeds().isEmpty == false { + destinations.append(.brackets(tournament)) + if seededTeams.isEmpty { + self._selectedDestination = State(wrappedValue: .brackets(tournament)) + } + } destinations.append(.teams(tournament)) self.allDestinations = destinations } @@ -109,6 +127,8 @@ struct TournamentCallView: View { TeamsCallingView(teams: tournament.selectedSortedTeams()) case .groupStages: GroupStageCallingView() + case .brackets: + BracketCallingView(tournament: tournament) case .seeds: SeedsCallingView() }