diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index f1165bc..8e3a6a6 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -178,6 +178,8 @@ FFC1E1082BAC29FC008D6F59 /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC1E1072BAC29FC008D6F59 /* LocationManager.swift */; }; FFC1E10A2BAC2A77008D6F59 /* NetworkFederalService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC1E1092BAC2A77008D6F59 /* NetworkFederalService.swift */; }; FFC1E10C2BAC7FB0008D6F59 /* ClubImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC1E10B2BAC7FB0008D6F59 /* ClubImportView.swift */; }; + FFC2DCB22BBE75D40046DB9F /* LoserBracketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC2DCB12BBE75D40046DB9F /* LoserBracketView.swift */; }; + FFC2DCB42BBE9ECD0046DB9F /* LoserRoundsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC2DCB32BBE9ECD0046DB9F /* LoserRoundsView.swift */; }; FFC83D4F2BB807D100750834 /* RoundsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC83D4E2BB807D100750834 /* RoundsView.swift */; }; FFC83D512BB8087E00750834 /* RoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC83D502BB8087E00750834 /* RoundView.swift */; }; FFCFBFFE2BBBE86600B82851 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = FFCFBFFD2BBBE86600B82851 /* Algorithms */; }; @@ -422,6 +424,8 @@ FFC1E1072BAC29FC008D6F59 /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = ""; }; FFC1E1092BAC2A77008D6F59 /* NetworkFederalService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkFederalService.swift; sourceTree = ""; }; FFC1E10B2BAC7FB0008D6F59 /* ClubImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClubImportView.swift; sourceTree = ""; }; + FFC2DCB12BBE75D40046DB9F /* LoserBracketView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoserBracketView.swift; sourceTree = ""; }; + FFC2DCB32BBE9ECD0046DB9F /* LoserRoundsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoserRoundsView.swift; sourceTree = ""; }; FFC83D4E2BB807D100750834 /* RoundsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundsView.swift; sourceTree = ""; }; FFC83D502BB8087E00750834 /* RoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundView.swift; sourceTree = ""; }; FFCFC0012BBC39A600B82851 /* EditScoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditScoreView.swift; sourceTree = ""; }; @@ -925,6 +929,8 @@ FFC83D4E2BB807D100750834 /* RoundsView.swift */, FFC83D502BB8087E00750834 /* RoundView.swift */, FF5DA1922BB9279B00A33061 /* RoundSettingsView.swift */, + FFC2DCB12BBE75D40046DB9F /* LoserBracketView.swift */, + FFC2DCB32BBE9ECD0046DB9F /* LoserRoundsView.swift */, ); path = Round; sourceTree = ""; @@ -1275,6 +1281,7 @@ FF6EC90B2B947AC000EA7F5A /* Array+Extensions.swift in Sources */, FF59FFB92B90EFD70061EFF9 /* ToolboxView.swift in Sources */, FFF8ACD92B923F3C008466FA /* String+Extensions.swift in Sources */, + FFC2DCB22BBE75D40046DB9F /* LoserBracketView.swift in Sources */, FFA6D7852BB0B795003A31F3 /* FileImportManager.swift in Sources */, FF6EC8FB2B94788600EA7F5A /* TournamentButtonView.swift in Sources */, FFF8ACCD2B92367B008466FA /* FederalPlayer.swift in Sources */, @@ -1293,6 +1300,7 @@ FFA6D7872BB0B7A2003A31F3 /* CloudConvert.swift in Sources */, FF1DC55B2BAB80C400FD8220 /* DisplayContext.swift in Sources */, C425D4032B6D249D002A7B48 /* ContentView.swift in Sources */, + FFC2DCB42BBE9ECD0046DB9F /* LoserRoundsView.swift in Sources */, FF967CFC2BAEE52E00A9A3BD /* GroupStagesView.swift in Sources */, FFD783FF2B91BA42000F62A6 /* PadelClubView.swift in Sources */, FFCFC0142BBC59FC00B82851 /* MatchDescriptor.swift in Sources */, diff --git a/PadelClub/Data/Match.swift b/PadelClub/Data/Match.swift index ef1623b..d1ced08 100644 --- a/PadelClub/Data/Match.swift +++ b/PadelClub/Data/Match.swift @@ -318,18 +318,22 @@ class Match: ModelObject, Storable { } func roundProjectedTeam(_ team: TeamData) -> TeamRegistration? { - guard round != nil else { return nil } - if let seed = seed(team) { + guard let roundObject else { return nil } + if roundObject.isLoserBracket() == false, let seed = seed(team) { return seed } - + let indexInRound = indexInRound() switch team { case .one: - if let teamId = topPreviousRoundMatch()?.winningTeamId { + if roundObject.isLoserBracket(), roundObject.previousRound() == nil, let parentRound = roundObject.parentRound, let loser = parentRound.matches.first(where: { parentRound.indexOfMatch($0) == indexInRound * 2 })?.losingTeamId { + return Store.main.findById(loser) + } else if let teamId = topPreviousRoundMatch()?.winningTeamId { return Store.main.findById(teamId) } case .two: - if let teamId = bottomPreviousRoundMatch()?.winningTeamId { + if roundObject.isLoserBracket(), roundObject.previousRound() == nil, let parentRound = roundObject.parentRound, let loser = parentRound.matches.first(where: { parentRound.indexOfMatch($0) == indexInRound * 2 + 1 })?.losingTeamId { + return Store.main.findById(loser) + } else if let teamId = bottomPreviousRoundMatch()?.winningTeamId { return Store.main.findById(teamId) } } diff --git a/PadelClub/Data/Round.swift b/PadelClub/Data/Round.swift index 5943fb5..05b2db6 100644 --- a/PadelClub/Data/Round.swift +++ b/PadelClub/Data/Round.swift @@ -52,15 +52,47 @@ class Round: ModelObject, Storable { } func previousRound() -> Round? { - Store.main.filter(isIncluded: { $0.tournament == tournament && $0.index == index + 1 }).first + Store.main.filter(isIncluded: { $0.tournament == tournament && $0.loser == loser && $0.index == index + 1 }).first } func nextRound() -> Round? { - Store.main.filter(isIncluded: { $0.tournament == tournament && $0.index == index - 1 }).first + Store.main.filter(isIncluded: { $0.tournament == tournament && $0.loser == loser && $0.index == index - 1 }).first + } + + func loserRounds(forRoundIndex roundIndex: Int) -> [Round] { + return loserRoundsAndChildren().filter({ $0.index == roundIndex }).sorted(by: \.cumulativeMatchCount) + } + + func getActiveLoserRound() -> Round? { + let rounds = loserRounds() + return rounds.filter({ $0.hasStarted() && $0.hasEnded() == false }).sorted(by: \.index).reversed().first ?? rounds.first + } + + var cumulativeMatchCount: Int { + var totalMatches = matches.count + if let parent = parentRound { + totalMatches += parent.cumulativeMatchCount + } + return totalMatches + } + + func initialRound() -> Round? { + if let parentRound { + return parentRound.initialRound() + } else { + return self + } } func roundTitle(_ displayStyle: DisplayStyle = .wide) -> String { - RoundRule.roundName(fromRoundIndex: index) + if let parentRound, let initialRound = parentRound.initialRound() { + let parentMatchCount = parentRound.cumulativeMatchCount - initialRound.matches.count + print("initialRound", initialRound.roundTitle()) + if let initialRoundNextRound = initialRound.nextRound()?.matches { + return SeedInterval(first: parentMatchCount + initialRoundNextRound.count * 2 + 1, last: parentMatchCount + initialRoundNextRound.count * 2 + matches.count * 2).localizedLabel(displayStyle) + } + } + return RoundRule.roundName(fromRoundIndex: index) } func roundStatus() -> String { @@ -71,16 +103,64 @@ class Round: ModelObject, Storable { } } - var loserRound: Round? { - guard let loser else { return nil } - return Store.main.findById(loser) + func indexOfMatch(_ match: Match) -> Int? { + matches.firstIndex(where: { $0.id == match.id }) + } + + func loserRounds() -> [Round] { + return Store.main.filter(isIncluded: { $0.loser == id }).sorted(by: \.index).reversed() + } + + func loserRoundsAndChildren() -> [Round] { + let loserRounds = loserRounds() + return loserRounds + loserRounds.flatMap({ $0.loserRoundsAndChildren() }) + } + + func isLoserBracket() -> Bool { + loser != nil + } + + func buildLoserBracket() { + guard loserRounds().isEmpty else { return } + let currentRoundMatchCount = matches.count + guard currentRoundMatchCount > 1 else { return } + let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount) + let loserBracketMatchFormat = tournamentObject()?.loserBracketMatchFormat + + let rounds = (0.. String { - roundTitle() + if let parentRound { + return "Tour #\(parentRound.loserRounds().count - index)" + } else { + return roundTitle() + } } func badgeValue() -> Int? { - nil + if let parentRound { + return parentRound.loserRounds(forRoundIndex: index).flatMap { $0.matches }.filter({ $0.isRunning() }).count + } else { + return matches.filter({ $0.isRunning() }).count + } } } diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index df68985..12fe20a 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -183,13 +183,6 @@ class Tournament : ModelObject, Storable { case 4...7: return SeedInterval(first: 5, last: 8) case 8...15: -// if 16 - 9 > availableSeeds().count { -// switch alreadySetupSeeds { -// case 8...15: -// return SeedInterval(first: 5, last: 8) -// case 8...15: -// return SeedInterval(first: 5, last: 8) -// } return SeedInterval(first: 9, last: 16) case 16...23: return SeedInterval(first: 17, last: 24) @@ -230,7 +223,7 @@ class Tournament : ModelObject, Storable { for (index, seed) in availableSeeds.enumerated() { seed.setSeedPosition(inSpot: spots[index], upperBranch: nil, opposingSeeding: false) } - } else if (availableSeeds.count <= availableSeedOpponentSpot.count && availableSeeds.count == self.availableSeeds().count) { + } else if (availableSeeds.count <= availableSeedOpponentSpot.count && availableSeeds.count <= self.availableSeeds().count) { let spots = availableSeedOpponentSpot.shuffled() for (index, seed) in availableSeeds.enumerated() { @@ -261,7 +254,7 @@ class Tournament : ModelObject, Storable { } func rounds() -> [Round] { - Store.main.filter { $0.tournament == self.id }.sorted(by: \.index).reversed() + Store.main.filter { $0.tournament == self.id && $0.loser == nil }.sorted(by: \.index).reversed() } func sortedTeams() -> [TeamRegistration] { @@ -619,6 +612,8 @@ class Tournament : ModelObject, Storable { }) try? DataStore.shared.matches.addOrUpdate(contentOfs: matches) + + buildLoserBracket() } func deleteStructure() { diff --git a/PadelClub/Views/Round/LoserBracketView.swift b/PadelClub/Views/Round/LoserBracketView.swift new file mode 100644 index 0000000..ae25942 --- /dev/null +++ b/PadelClub/Views/Round/LoserBracketView.swift @@ -0,0 +1,53 @@ +// +// LoserBracketView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 04/04/2024. +// + +import SwiftUI + +struct LoserBracketView: View { + @EnvironmentObject var dataStore: DataStore + let loserRounds: [Round] + + @ViewBuilder + var body: some View { + if let first = loserRounds.first { + List { + ForEach(loserRounds) { loserRound in + _loserRoundView(loserRound) + let childLoserRounds = loserRound.loserRounds() + if childLoserRounds.isEmpty == false { + let uniqueChildRound = childLoserRounds.first + if childLoserRounds.count == 1, let uniqueChildRound { + _loserRoundView(uniqueChildRound) + } else if let uniqueChildRound { + NavigationLink { + LoserBracketView(loserRounds: childLoserRounds) + } label: { + Text(uniqueChildRound.roundTitle()) + } + } + } + } + } + .navigationTitle(first.roundTitle()) + } + } + + private func _loserRoundView(_ loserRound: Round) -> some View { + Section { + ForEach(loserRound.matches) { match in + MatchRowView(match: match, matchViewStyle: .standardStyle) + } + } header: { + Text(loserRound.roundTitle()) + } + } +} + +#Preview { + LoserBracketView(loserRounds: [Round.mock()]) + .environmentObject(DataStore.shared) +} diff --git a/PadelClub/Views/Round/LoserRoundsView.swift b/PadelClub/Views/Round/LoserRoundsView.swift new file mode 100644 index 0000000..46e5609 --- /dev/null +++ b/PadelClub/Views/Round/LoserRoundsView.swift @@ -0,0 +1,65 @@ +// +// LoserRoundsView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 04/04/2024. +// + +import SwiftUI + +extension Int: Selectable, Identifiable { + public var id: Int { self } + func selectionLabel() -> String { + "Tour #\(self + 1)" + } + + func badgeValue() -> Int? { + nil + } +} + +struct LoserRoundsView: View { + var upperBracketRound: Round + @State private var selectedRound: Round? + let loserRounds: [Round] + + init(upperBracketRound: Round) { + self.upperBracketRound = upperBracketRound + self.loserRounds = upperBracketRound.loserRounds() + _selectedRound = State(wrappedValue: upperBracketRound.getActiveLoserRound()) + } + + var body: some View { + VStack(spacing: 0) { + GenericDestinationPickerView(selectedDestination: $selectedRound, destinations: loserRounds, nilDestinationIsValid: true) + switch selectedRound { + case .none: + List { + } + case .some(let selectedRound): + LoserRoundView(loserRounds: upperBracketRound.loserRounds(forRoundIndex: selectedRound.index)) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + } +} + +struct LoserRoundView: View { + let loserRounds: [Round] + + var body: some View { + List { + ForEach(loserRounds) { loserRound in + Section { + ForEach(loserRound.matches) { match in + MatchRowView(match: match, matchViewStyle: .sectionedStandardStyle) + } + } header: { + Text(loserRound.roundTitle(.wide)) + } + } + } + .headerProminence(.increased) + } +} diff --git a/PadelClub/Views/Round/RoundSettingsView.swift b/PadelClub/Views/Round/RoundSettingsView.swift index b3ad616..d492403 100644 --- a/PadelClub/Views/Round/RoundSettingsView.swift +++ b/PadelClub/Views/Round/RoundSettingsView.swift @@ -23,14 +23,28 @@ struct RoundSettingsView: View { Toggle("Éditer les têtes de série", isOn: $isEditingTournamentSeed) Section { - RowButtonView("Retirer toutes les têtes de séries") { + RowButtonView("Effacer classement", role: .destructive) { + tournament.rounds().forEach { round in + try? dataStore.rounds.delete(contentOfs: round.loserRounds()) + } + } + } + Section { + RowButtonView("Match de classement") { + tournament.rounds().forEach { round in + round.buildLoserBracket() + } + } + } + Section { + RowButtonView("Retirer toutes les têtes de séries", role: .destructive) { tournament.unsortedTeams().forEach({ $0.bracketPosition = nil }) } } Section { if let lastRound = tournament.rounds().first { // first is final, last round - RowButtonView("Supprimer " + lastRound.roundTitle()) { + RowButtonView("Supprimer " + lastRound.roundTitle(), role: .destructive) { try? dataStore.rounds.delete(instance: lastRound) } } @@ -47,6 +61,7 @@ struct RoundSettingsView: View { } try? dataStore.rounds.addOrUpdate(instance: round) try? dataStore.matches.addOrUpdate(contentOfs: matches) + round.buildLoserBracket() } } diff --git a/PadelClub/Views/Round/RoundView.swift b/PadelClub/Views/Round/RoundView.swift index d018085..bd01ac7 100644 --- a/PadelClub/Views/Round/RoundView.swift +++ b/PadelClub/Views/Round/RoundView.swift @@ -12,6 +12,18 @@ struct RoundView: View { var body: some View { List { + let loserRounds = round.loserRounds() + if loserRounds.isEmpty == false, let first = loserRounds.first { + Section { + NavigationLink { + LoserRoundsView(upperBracketRound: round) + .navigationTitle(first.roundTitle()) + } label: { + Text(first.roundTitle()) + } + } + } + ForEach(round.matches) { match in Section { MatchRowView(match: match, matchViewStyle: .sectionedStandardStyle)