From 509a1e3423f2482ca79b7cacc8c69b3a08e5eaa4 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Wed, 27 Mar 2024 09:26:12 +0100 Subject: [PATCH] clean up --- PadelClub.xcodeproj/project.pbxproj | 8 + PadelClub/Data/Event.swift | 5 + PadelClub/Data/PlayerRegistration.swift | 28 +- PadelClub/Data/TeamRegistration.swift | 90 +- PadelClub/Data/Tournament.swift | 146 +++- PadelClub/Extensions/MySortDescriptor.swift | 27 + .../Extensions/Sequence+Extensions.swift | 39 + PadelClub/Manager/FileImportManager.swift | 10 +- PadelClub/Manager/PadelRule.swift | 2 +- PadelClub/Manager/SourceFileManager.swift | 3 +- .../Navigation/Agenda/EventListView.swift | 6 +- .../Views/Shared/LearnMoreSheetView.swift | 47 ++ .../Views/Tournament/FileImportView.swift | 45 +- .../Components/UpdateSourceRankDateView.swift | 44 +- .../Screen/InscriptionManagerView.swift | 791 +++++++++++------- 15 files changed, 890 insertions(+), 401 deletions(-) create mode 100644 PadelClub/Extensions/MySortDescriptor.swift create mode 100644 PadelClub/Views/Shared/LearnMoreSheetView.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 758e0a9..6186e26 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -100,6 +100,7 @@ FF59FFB32B90EFAC0061EFF9 /* EventListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF59FFB22B90EFAC0061EFF9 /* EventListView.swift */; }; FF59FFB72B90EFBF0061EFF9 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF59FFB62B90EFBF0061EFF9 /* MainView.swift */; }; FF59FFB92B90EFD70061EFF9 /* ToolboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF59FFB82B90EFD70061EFF9 /* ToolboxView.swift */; }; + FF5D0D722BB3EFA5005CB568 /* LearnMoreSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5D0D6F2BB3EFA5005CB568 /* LearnMoreSheetView.swift */; }; FF6EC8F72B94773200EA7F5A /* RowButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6EC8F62B94773100EA7F5A /* RowButtonView.swift */; }; FF6EC8FB2B94788600EA7F5A /* TournamentButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6EC8FA2B94788600EA7F5A /* TournamentButtonView.swift */; }; FF6EC8FE2B94792300EA7F5A /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6EC8FD2B94792300EA7F5A /* Screen.swift */; }; @@ -159,6 +160,7 @@ FFD784022B91C1B4000F62A6 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD784012B91C1B4000F62A6 /* WelcomeView.swift */; }; FFD784042B91C280000F62A6 /* EmptyActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD784032B91C280000F62A6 /* EmptyActivityView.swift */; }; FFDB1C6D2BB2A02000F1E467 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFDB1C6C2BB2A02000F1E467 /* AppSettings.swift */; }; + FFDB1C732BB2CFE900F1E467 /* MySortDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFDB1C722BB2CFE900F1E467 /* MySortDescriptor.swift */; }; FFDDD40C2B93B2BB00C91A49 /* DeferredViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFDDD40B2B93B2BB00C91A49 /* DeferredViewModifier.swift */; }; FFF8ACCD2B92367B008466FA /* FederalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF8ACCC2B92367B008466FA /* FederalPlayer.swift */; }; FFF8ACD42B92392C008466FA /* SourceFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF8ACD32B92392C008466FA /* SourceFileManager.swift */; }; @@ -308,6 +310,7 @@ FF59FFB22B90EFAC0061EFF9 /* EventListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventListView.swift; sourceTree = ""; }; FF59FFB62B90EFBF0061EFF9 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; FF59FFB82B90EFD70061EFF9 /* ToolboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolboxView.swift; sourceTree = ""; }; + FF5D0D6F2BB3EFA5005CB568 /* LearnMoreSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreSheetView.swift; sourceTree = ""; }; FF6EC8F62B94773100EA7F5A /* RowButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowButtonView.swift; sourceTree = ""; }; FF6EC8FA2B94788600EA7F5A /* TournamentButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentButtonView.swift; sourceTree = ""; }; FF6EC8FD2B94792300EA7F5A /* Screen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; }; @@ -369,6 +372,7 @@ FFD784012B91C1B4000F62A6 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; FFD784032B91C280000F62A6 /* EmptyActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyActivityView.swift; sourceTree = ""; }; FFDB1C6C2BB2A02000F1E467 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; + FFDB1C722BB2CFE900F1E467 /* MySortDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MySortDescriptor.swift; sourceTree = ""; }; FFDDD40B2B93B2BB00C91A49 /* DeferredViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredViewModifier.swift; sourceTree = ""; }; FFF8ACCC2B92367B008466FA /* FederalPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FederalPlayer.swift; sourceTree = ""; }; FFF8ACD32B92392C008466FA /* SourceFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceFileManager.swift; sourceTree = ""; }; @@ -742,6 +746,7 @@ FF8F26502BAE0BAD00650388 /* MatchFormatPickerView.swift */, FF4AB6BC2B9256E10002987F /* SelectablePlayerListView.swift */, FF4AB6BE2B92577A0002987F /* ImportedPlayerView.swift */, + FF5D0D6F2BB3EFA5005CB568 /* LearnMoreSheetView.swift */, ); path = Shared; sourceTree = ""; @@ -872,6 +877,7 @@ FF6EC9032B9479F500EA7F5A /* Sequence+Extensions.swift */, FF6EC9082B947A5300EA7F5A /* FixedWidthInteger+Extensions.swift */, FF6EC90A2B947AC000EA7F5A /* Array+Extensions.swift */, + FFDB1C722BB2CFE900F1E467 /* MySortDescriptor.swift */, ); path = Extensions; sourceTree = ""; @@ -1140,6 +1146,7 @@ FF089EBB2BB0120700F0AEC7 /* PlayerPopoverView.swift in Sources */, FF70916E2B9108C600AB08DA /* InscriptionManagerView.swift in Sources */, FF82CFC92B9132AF00B0CAF2 /* ActivityView.swift in Sources */, + FFDB1C732BB2CFE900F1E467 /* MySortDescriptor.swift in Sources */, FF8F26472BAE0ACB00650388 /* TournamentFieldsManagerView.swift in Sources */, FF967D0B2BAF3D4C00A9A3BD /* TeamPickerView.swift in Sources */, FFA6D7872BB0B7A2003A31F3 /* CloudConvert.swift in Sources */, @@ -1178,6 +1185,7 @@ FF967CF82BAEDF0000A9A3BD /* Labels.swift in Sources */, FF089EB42BB0020000F0AEC7 /* PlayerSexPickerView.swift in Sources */, FF70916A2B90F95E00AB08DA /* DateBoxView.swift in Sources */, + FF5D0D722BB3EFA5005CB568 /* LearnMoreSheetView.swift in Sources */, FFF8ACD42B92392C008466FA /* SourceFileManager.swift in Sources */, FF0EC5222BB173E70056B6D1 /* UpdateSourceRankDateView.swift in Sources */, C4A47D912B7BBBEC00ADC637 /* Guard.swift in Sources */, diff --git a/PadelClub/Data/Event.swift b/PadelClub/Data/Event.swift index 692c530..09e7684 100644 --- a/PadelClub/Data/Event.swift +++ b/PadelClub/Data/Event.swift @@ -32,6 +32,11 @@ class Event: ModelObject, Storable { self.roundFormat = roundFormat self.loserRoundFormat = loserRoundFormat } + + var clubObject: Club? { + guard let club else { return nil } + return Store.main.findById(club) + } var tournaments: [Tournament] { Store.main.filter { $0.event == self.id } diff --git a/PadelClub/Data/PlayerRegistration.swift b/PadelClub/Data/PlayerRegistration.swift index 22945ef..74e3caf 100644 --- a/PadelClub/Data/PlayerRegistration.swift +++ b/PadelClub/Data/PlayerRegistration.swift @@ -33,8 +33,9 @@ class PlayerRegistration: ModelObject, Storable { var birthdate: String? var weight: Int = 0 + var source: PlayerDataSource? - internal init(teamRegistration: String = "", firstName: String, lastName: String, licenceId: String? = nil, rank: Int? = nil, registrationType: Int? = nil, registrationDate: Date? = nil, sex: Int) { + internal init(teamRegistration: String? = nil, firstName: String, lastName: String, licenceId: String? = nil, rank: Int? = nil, registrationType: Int? = nil, registrationDate: Date? = nil, sex: Int, source: PlayerDataSource? = nil) { self.teamRegistration = teamRegistration self.firstName = firstName self.lastName = lastName @@ -43,6 +44,7 @@ class PlayerRegistration: ModelObject, Storable { self.registrationType = registrationType self.registrationDate = registrationDate self.sex = sex + self.source = source } internal init(importedPlayer: ImportedPlayer) { @@ -57,6 +59,7 @@ class PlayerRegistration: ModelObject, Storable { self.clubName = importedPlayer.clubName self.ligueName = importedPlayer.ligueName self.assimilation = importedPlayer.assimilation + self.source = .frenchFederation } internal init(federalData: [String], sex: Int, sexUnknown: Bool) { @@ -68,7 +71,7 @@ class PlayerRegistration: ModelObject, Storable { rank = Int(federalData[5]) email = federalData[6] phoneNumber = federalData[7] -// manuallyCreated = false + source = .beachPadel if sexUnknown { if sex == 1 && FileImportManager.shared.foundInWomenData(license: federalData[3]) { self.sex = 0 @@ -82,6 +85,9 @@ class PlayerRegistration: ModelObject, Storable { } } + func pasteData() -> String { + [firstName.capitalized, lastName.capitalized, licenceId].compactMap({ $0 }).joined(separator: " ") + } func contains(_ searchField: String) -> Bool { firstName.localizedCaseInsensitiveContains(searchField) || lastName.localizedCaseInsensitiveContains(searchField) @@ -116,7 +122,17 @@ class PlayerRegistration: ModelObject, Storable { } func playerLabel(_ displayStyle: DisplayStyle = .wide) -> String { - lastName.trimmed.capitalized + " " + firstName.trimmed.capitalized + switch displayStyle { + case .wide: + lastName.trimmed.capitalized + " " + firstName.trimmed.capitalized + case .short: + lastName.trimmed.capitalized + " " + firstName.trimmed.prefix(1).capitalized + "." + } + } + + @objc + var canonicalName: String { + playerLabel().folding(options: .diacriticInsensitive, locale: .current).lowercased() } func rankLabel(_ displayStyle: DisplayStyle = .wide) -> String { @@ -219,9 +235,15 @@ class PlayerRegistration: ModelObject, Storable { case _phoneNumber = "phoneNumber" case _email = "email" case _weight = "weight" + case _source = "source" } + enum PlayerDataSource: Int, Codable { + case frenchFederation + case beachPadel + } + enum PaymentType: Int, CaseIterable, Identifiable { var id: Self { self diff --git a/PadelClub/Data/TeamRegistration.swift b/PadelClub/Data/TeamRegistration.swift index f7bf45d..99f380f 100644 --- a/PadelClub/Data/TeamRegistration.swift +++ b/PadelClub/Data/TeamRegistration.swift @@ -30,6 +30,7 @@ class TeamRegistration: ModelObject, Storable { var wildCardGroupStage: Bool = false var category: Int? var weight: Int = 0 + var lockWeight: Int? internal init(tournament: String, groupStage: String? = nil, registrationDate: Date? = nil, callDate: Date? = nil, bracketPosition: Int? = nil, groupStagePosition: Int? = nil, comment: String? = nil, source: String? = nil, sourceValue: String? = nil, logo: String? = nil, name: String? = nil, category: Int? = nil) { self.tournament = tournament @@ -46,6 +47,18 @@ class TeamRegistration: ModelObject, Storable { self.category = category } + var initialWeight: Int { + lockWeight ?? weight + } + + func isImported() -> Bool { + unsortedPlayers().allSatisfy({ $0.source == .beachPadel }) + } + + func isWildCard() -> Bool { + wildCardBracket || wildCardGroupStage + } + var tournamentCategory: TournamentCategory { get { TournamentCategory(rawValue: category ?? 0) ?? .men @@ -55,12 +68,33 @@ class TeamRegistration: ModelObject, Storable { } } + @objc + var canonicalName: String { + players().map { $0.canonicalName }.joined(separator: " ") + } + + func hasMemberOfClub(_ codeClubOrClubName: String?) -> Bool { + guard let codeClubOrClubName else { return true } + return unsortedPlayers().anySatisfy({ + $0.clubName?.contains(codeClubOrClubName) == true || $0.clubName?.contains(codeClubOrClubName) == true + }) + } + func teamLabel(_ displayStyle: DisplayStyle = .wide) -> String { - unsortedPlayers().map { $0.playerLabel(displayStyle) }.joined(separator: " & ") + switch displayStyle { + case .wide: + unsortedPlayers().map { $0.playerLabel(displayStyle) }.joined(separator: " & ") + case .short: + unsortedPlayers().map { $0.playerLabel(.wide) }.joined(separator: "\n") + } } + func index(in teams: [TeamRegistration]) -> Int? { + teams.firstIndex(where: { $0.id == id }) + } + func formattedSeed(in teams: [TeamRegistration]) -> String { - if let index = teams.firstIndex(where: { $0.id == id }) { + if let index = index(in: teams) { return "#\(index + 1)" } else { return "###" @@ -87,21 +121,36 @@ class TeamRegistration: ModelObject, Storable { groupStagePosition ?? -1 } - func updatePlayers(_ players: Set) { - self.unsortedPlayers().forEach { player in - if players.contains(player) == false { - try? DataStore.shared.playerRegistrations.delete(instance: player) - } + func resetPositions() { + groupStage = nil + groupStagePosition = nil + bracketPosition = nil + } + + func pasteData() -> String { + [name, playersPasteData(), formattedInscriptionDate()].compactMap({ $0 }).joined(separator: "\n") + } + + func formattedInscriptionDate() -> String? { + if let registrationDate { + return "Inscrit le " + registrationDate.formatted(.dateTime.weekday().day().month().hour().minute()) + } else { + return nil } - + } + + func playersPasteData() -> String { + players().map { $0.pasteData() }.joined(separator: "\n") + } + + + func updatePlayers(_ players: Set) { + try? DataStore.shared.playerRegistrations.delete(contentOfs: unsortedPlayers()) setWeight(from: Array(players)) players.forEach { player in player.teamRegistration = id - try? DataStore.shared.playerRegistrations.addOrUpdate(instance: player) - } - - try? DataStore.shared.teamRegistrations.addOrUpdate(instance: self) + } } func qualified() -> Bool { @@ -157,7 +206,7 @@ class TeamRegistration: ModelObject, Storable { func missingPlayerType() -> [Int] { let players = unsortedPlayers() - if players.count < 2 { return [] } + if players.count >= 2 { return [] } let s = players.map { $0.sex } var missing = mandatoryPlayerType() s.forEach { i in @@ -198,5 +247,20 @@ class TeamRegistration: ModelObject, Storable { case _category = "category" case _weight = "weight" case _walkOut = "walkOut" + case _lockWeight = "lockWeight" + } +} + +extension TeamRegistration: Hashable { + static func == (lhs: TeamRegistration, rhs: TeamRegistration) -> Bool { + lhs.id == rhs.id } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +enum TeamDataSource: Int, Codable { + case beachPadel } diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index 555e993..ed7df4a 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -28,7 +28,7 @@ class Tournament : ModelObject, Storable { var rankSourceDate: Date? var dayDuration: Int var teamCount: Int - var teamSorting: Int + var teamSorting: TeamSortingType var federalCategory: Int var federalLevelCategory: Int var federalAgeCategory: Int @@ -50,7 +50,7 @@ class Tournament : ModelObject, Storable { @ObservationIgnored var undoManager: Int = 0 - internal init(event: String? = nil, creator: String? = nil, name: String? = nil, startDate: Date = Date(), endDate: Date? = nil, creationDate: Date = Date(), isPrivate: Bool = true, groupStageFormat: Int? = nil, roundFormat: Int? = nil, loserRoundFormat: Int? = nil, groupStageSortMode: GroupStageOrderingMode, groupStageCount: Int = 4, rankSourceDate: Date? = nil, dayDuration: Int = 1, teamCount: Int = 24, teamSorting: TeamSortingType, federalCategory: TournamentCategory, federalLevelCategory: TournamentLevel, federalAgeCategory: FederalTournamentAge, groupStageCourtCount: Int? = nil, seedCount: Int = 8, closedRegistrationDate: Date? = nil, groupStageAdditionalQualified: Int = 0, courtCount: Int = 2, prioritizeClubMembers: Bool = false, qualifiedPerGroupStage: Int = 1, teamsPerGroupStage: Int = 4, entryFee: Double? = nil, maleUnrankedValue: Int? = nil, femaleUnrankedValue: Int? = nil) { + internal init(event: String? = nil, creator: String? = nil, name: String? = nil, startDate: Date = Date(), endDate: Date? = nil, creationDate: Date = Date(), isPrivate: Bool = true, groupStageFormat: Int? = nil, roundFormat: Int? = nil, loserRoundFormat: Int? = nil, groupStageSortMode: GroupStageOrderingMode, groupStageCount: Int = 4, rankSourceDate: Date? = nil, dayDuration: Int = 1, teamCount: Int = 24, teamSorting: TeamSortingType? = nil, federalCategory: TournamentCategory, federalLevelCategory: TournamentLevel, federalAgeCategory: FederalTournamentAge, groupStageCourtCount: Int? = nil, seedCount: Int = 8, closedRegistrationDate: Date? = nil, groupStageAdditionalQualified: Int = 0, courtCount: Int = 2, prioritizeClubMembers: Bool = false, qualifiedPerGroupStage: Int = 1, teamsPerGroupStage: Int = 4, entryFee: Double? = nil, maleUnrankedValue: Int? = nil, femaleUnrankedValue: Int? = nil) { self.event = event self.creator = creator self.name = name @@ -66,7 +66,7 @@ class Tournament : ModelObject, Storable { self.rankSourceDate = rankSourceDate self.dayDuration = dayDuration self.teamCount = teamCount - self.teamSorting = teamSorting.rawValue + //self.teamSorting = teamSorting.rawValue self.federalCategory = federalCategory.rawValue self.federalLevelCategory = federalLevelCategory.rawValue self.federalAgeCategory = federalAgeCategory.rawValue @@ -81,12 +81,27 @@ class Tournament : ModelObject, Storable { self.entryFee = entryFee self.maleUnrankedValue = maleUnrankedValue self.femaleUnrankedValue = femaleUnrankedValue + self.teamSorting = teamSorting ?? federalLevelCategory.defaultTeamSortingType } enum State { case initial case build } + + var eventObject: Event? { + guard let event else { return nil } + return Store.main.findById(event) + } + + func pasteDataForImporting() -> String { + let selectedSortedTeams = selectedSortedTeams() + return (selectedSortedTeams.compactMap { $0.pasteData() } + ["Liste d'attente"] + waitingListTeams(in: selectedSortedTeams).compactMap { $0.pasteData() }).joined(separator: "\n\n") + } + + func club() -> Club? { + eventObject?.clubObject + } func hasEnded() -> Bool { endDate != nil @@ -107,6 +122,74 @@ class Tournament : ModelObject, Storable { Store.main.filter { $0.tournament == self.id }.sorted(by: \.index) } + var clubName: String? { + nil + } + + func sortedTeams() -> [TeamRegistration] { + let teams = selectedSortedTeams() + return teams + waitingListTeams(in: teams) + } + + func selectedSortedTeams() -> [TeamRegistration] { + let start = Date() + var _sortedTeams : [TeamRegistration] = [] + let _teams = unsortedTeams().filter({ $0.walkOut == false }) + + let defaultSorting : [MySortDescriptor] = _defaultSorting() + + let _completeTeams = _teams.sorted(using: defaultSorting, order: .ascending).filter { $0.isWildCard() == false} + + let wcGroupStage = _teams.filter { $0.wildCardGroupStage }.sorted(using: _currentSelectionSorting, order: .ascending) + + let wcBracket = _teams.filter { $0.wildCardBracket }.sorted(using: _currentSelectionSorting, order: .ascending) + + var bracketSeeds = min(teamCount, _completeTeams.count) - groupStageCount * teamsPerGroupStage - wcBracket.count + var groupStageTeamCount = groupStageCount * teamsPerGroupStage - wcGroupStage.count + if groupStageTeamCount < 0 { groupStageTeamCount = 0 } + if bracketSeeds < 0 { bracketSeeds = 0 } + + if prioritizeClubMembers { + + let bracketTeams = (_completeTeams.filter { $0.hasMemberOfClub(clubName) } + _completeTeams.filter { $0.hasMemberOfClub(clubName) == false }.sorted(using: defaultSorting, order: .ascending)).prefix(bracketSeeds).sorted(using: _currentSelectionSorting, order: .ascending) + wcBracket + + let groupStageTeamsNoFiltering = Set(_completeTeams).subtracting(bracketTeams) + let groupStageTeams = (groupStageTeamsNoFiltering.filter { $0.hasMemberOfClub(clubName) } + groupStageTeamsNoFiltering.filter { $0.hasMemberOfClub(clubName) == false }.sorted(using: defaultSorting, order: .ascending)).prefix(groupStageTeamCount).sorted(using: _currentSelectionSorting, order: .ascending) + wcGroupStage + + _sortedTeams = bracketTeams.sorted(using: _currentSelectionSorting, order: .ascending) + groupStageTeams.sorted(using: _currentSelectionSorting, order: .ascending) + } else { + let bracketTeams = _completeTeams.prefix(bracketSeeds).sorted(using: _currentSelectionSorting, order: .ascending) + wcBracket + let groupStageTeams = Set(_completeTeams).subtracting(bracketTeams).sorted(using: defaultSorting, order: .ascending).prefix(groupStageTeamCount).sorted(using: _currentSelectionSorting, order: .ascending) + wcGroupStage + _sortedTeams = bracketTeams.sorted(using: _currentSelectionSorting, order: .ascending) + groupStageTeams.sorted(using: _currentSelectionSorting, order: .ascending) + } + + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print(id, title(), duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + return _sortedTeams + } + + func waitingListTeams(in teams: [TeamRegistration]) -> [TeamRegistration] { + Set(unsortedTeams()).subtracting(teams).sorted(using: _defaultSorting(), order: .ascending) + } + + func bracketCut() -> Int { + max(0, teamCount - groupStageCut()) + } + + func groupStageCut() -> Int { + groupStageCount * teamsPerGroupStage + } + + func cutLabel(index: Int) -> String { + if index < bracketCut() { + return "Tableau" + } else if index - bracketCut() < groupStageCut() { + return "Poule" + } else { + return "Liste d'attente" + } + } + func unsortedTeams() -> [TeamRegistration] { Store.main.filter { $0.tournament == self.id } } @@ -176,10 +259,30 @@ class Tournament : ModelObject, Storable { 2 } - func importTeams(_ teams: [FileImportManager.TeamHolder], keepPreviousData: Bool = false) { + func importTeams(_ teams: [FileImportManager.TeamHolder]) { + var teamsToImport = [TeamRegistration]() teams.forEach { team in - addTeam(Set([team.playerOne, team.playerTwo])) + if let previousTeam = team.previousTeam { + previousTeam.updatePlayers(team.players) + teamsToImport.append(previousTeam) + } else { + let newTeam = addTeam(team.players) + teamsToImport.append(newTeam) + } } + + try? DataStore.shared.teamRegistrations.addOrUpdate(contentOfs: teamsToImport) + try? DataStore.shared.playerRegistrations.addOrUpdate(contentOfs: teams.flatMap { $0.players }) + + } + + func lockRegistration() { + closedRegistrationDate = Date() + let teams = unsortedTeams() + teams.forEach { team in + team.lockWeight = team.weight + } + try? DataStore.shared.teamRegistrations.addOrUpdate(contentOfs: teams) } func updateWeights() { @@ -213,7 +316,7 @@ class Tournament : ModelObject, Storable { // if inscriptionClosed == false { // orderedEntries.forEach { entrant in -// entrant.initialRank = entrant.updatedRank +// entrant.weightAtRegistration = entrant.updatedRank // } // } } @@ -350,7 +453,7 @@ class Tournament : ModelObject, Storable { func setBrackets(randomize: Bool) { let groupStages = groupStages() let numberOfBracketsAsInt = groupStages.count -// let teamsPerBracket = Int(teamsPerBracket) +// let teamsPerBracket = teamsPerBracket if groupStageCount != numberOfBracketsAsInt { buildGroupStages() return @@ -380,26 +483,16 @@ class Tournament : ModelObject, Storable { entryFee == nil || entryFee == 0 } - func addTeam(_ players: Set) { + func addTeam(_ players: Set) -> TeamRegistration { let team = TeamRegistration(tournament: id, registrationDate: Date()) team.tournamentCategory = tournamentCategory team.setWeight(from: Array(players)) - try? DataStore.shared.teamRegistrations.addOrUpdate(instance: team) players.forEach { player in player.teamRegistration = team.id } - try? DataStore.shared.playerRegistrations.addOrUpdate(contentOfs: players) + return team } - var teamSortingType: TeamSortingType { - get { - TeamSortingType(rawValue: teamSorting) ?? tournamentLevel.defaultTeamSortingType - } - set { - teamSorting = newValue.rawValue - } - } - var matchFormat: MatchFormat { get { MatchFormat(rawValue: roundFormat ?? 0) ?? .defaultFormatForMatchType(.bracket) @@ -456,7 +549,7 @@ class Tournament : ModelObject, Storable { } set { federalLevelCategory = newValue.rawValue - teamSortingType = newValue.defaultTeamSortingType + teamSorting = newValue.defaultTeamSortingType groupStageMatchFormat = groupStageSmartMatchFormat() loserBracketMatchFormat = loserBracketSmartMatchFormat(1) matchFormat = roundSmartMatchFormat(1) @@ -501,7 +594,18 @@ class Tournament : ModelObject, Storable { return matchFormat } } - + + private func _defaultSorting() -> [MySortDescriptor] { + switch teamSorting { + case .rank: + [.keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.registrationDate!), .keyPath(\.canonicalName)] + case .inscriptionDate: + [.keyPath(\TeamRegistration.registrationDate!), .keyPath(\TeamRegistration.initialWeight), .keyPath(\.canonicalName)] + } + } + + private let _currentSelectionSorting : [MySortDescriptor] = [.keyPath(\.weight), .keyPath(\.registrationDate!), .keyPath(\.canonicalName)] + override func deleteDependencies() throws { try Store.main.deleteDependencies(items: self.unsortedTeams()) try Store.main.deleteDependencies(items: self.groupStages()) diff --git a/PadelClub/Extensions/MySortDescriptor.swift b/PadelClub/Extensions/MySortDescriptor.swift new file mode 100644 index 0000000..45e2efb --- /dev/null +++ b/PadelClub/Extensions/MySortDescriptor.swift @@ -0,0 +1,27 @@ +// +// MySortDescriptor.swift +// PadelClub +// +// Created by Razmig Sarkissian on 26/03/2024. +// + +import Foundation + +struct MySortDescriptor { + var comparator: (Value, Value) -> ComparisonResult +} + +extension MySortDescriptor { + static func keyPath(_ keyPath: KeyPath) -> Self { + Self { rootA, rootB in + let valueA = rootA[keyPath: keyPath] + let valueB = rootB[keyPath: keyPath] + + guard valueA != valueB else { + return .orderedSame + } + + return valueA < valueB ? .orderedAscending : .orderedDescending + } + } +} diff --git a/PadelClub/Extensions/Sequence+Extensions.swift b/PadelClub/Extensions/Sequence+Extensions.swift index 90808ff..cb5a0f8 100644 --- a/PadelClub/Extensions/Sequence+Extensions.swift +++ b/PadelClub/Extensions/Sequence+Extensions.swift @@ -37,3 +37,42 @@ extension Sequence { } } } + +enum SortOrder { + case ascending + case descending +} + +extension Sequence { + func sorted(using descriptors: [MySortDescriptor], + order: SortOrder) -> [Element] { + sorted { valueA, valueB in + for descriptor in descriptors { + let result = descriptor.comparator(valueA, valueB) + + switch result { + case .orderedSame: + // Keep iterating if the two elements are equal, + // since that'll let the next descriptor determine + // the sort order: + break + case .orderedAscending: + return order == .ascending + case .orderedDescending: + return order == .descending + } + } + + // If no descriptor was able to determine the sort + // order, we'll default to false (similar to when + // using the '<' operator with the built-in API): + return false + } + } +} +extension Sequence { + func sorted(using descriptors: MySortDescriptor...) -> [Element] { + sorted(using: descriptors, order: .ascending) + } +} + diff --git a/PadelClub/Manager/FileImportManager.swift b/PadelClub/Manager/FileImportManager.swift index 327ce5b..ac224b4 100644 --- a/PadelClub/Manager/FileImportManager.swift +++ b/PadelClub/Manager/FileImportManager.swift @@ -72,8 +72,16 @@ class FileImportManager { self.weight = playerOne.weight + playerTwo.weight } + var players: Set { + Set([playerOne, playerTwo]) + } + + func index(in teams: [TeamHolder]) -> Int? { + teams.firstIndex(where: { $0.id == id }) + } + func formattedSeed(in teams: [TeamHolder]) -> String { - if let index = teams.firstIndex(where: { $0.id == id }) { + if let index = index(in: teams) { return "#\(index + 1)" } else { return "###" diff --git a/PadelClub/Manager/PadelRule.swift b/PadelClub/Manager/PadelRule.swift index f04aade..3327b54 100644 --- a/PadelClub/Manager/PadelRule.swift +++ b/PadelClub/Manager/PadelRule.swift @@ -1327,7 +1327,7 @@ enum EventType: Int, CaseIterable, Identifiable { } } -enum TeamSortingType: Int, Identifiable, CaseIterable, Hashable { +enum TeamSortingType: Int, Identifiable, CaseIterable, Hashable, Codable { case rank = 1 case inscriptionDate = 2 diff --git a/PadelClub/Manager/SourceFileManager.swift b/PadelClub/Manager/SourceFileManager.swift index 02ec6e8..bd20172 100644 --- a/PadelClub/Manager/SourceFileManager.swift +++ b/PadelClub/Manager/SourceFileManager.swift @@ -9,7 +9,8 @@ import Foundation class SourceFileManager { static let shared = SourceFileManager() - + static let beachPadel = URL(string: "https://beach-padel.app.fft.fr/beachja/index/")! + var lastDataSource: String? { UserDefaults.standard.string(forKey: "lastDataSource") } diff --git a/PadelClub/Views/Navigation/Agenda/EventListView.swift b/PadelClub/Views/Navigation/Agenda/EventListView.swift index fc60328..013e716 100644 --- a/PadelClub/Views/Navigation/Agenda/EventListView.swift +++ b/PadelClub/Views/Navigation/Agenda/EventListView.swift @@ -16,7 +16,11 @@ struct EventListView: View { ForEach(tournaments) { tournament in NavigationLink(value: tournament) { - TournamentCellView(tournament: tournament) + HStack { + TournamentCellView(tournament: tournament) + Spacer() + Text(tournament.sortedTeams().count.formatted()) + } } .contextMenu { Button { diff --git a/PadelClub/Views/Shared/LearnMoreSheetView.swift b/PadelClub/Views/Shared/LearnMoreSheetView.swift new file mode 100644 index 0000000..fa64b4e --- /dev/null +++ b/PadelClub/Views/Shared/LearnMoreSheetView.swift @@ -0,0 +1,47 @@ +/* +See the LICENSE.txt file for this sample’s licensing information. + +Abstract: +A view that gets displayed when the learn more action buttons is tapped. +*/ + +import SwiftUI + +struct LearnMoreSheetView: View { + @Environment(\.dismiss) private var dismiss + var tournament: Tournament + + var body: some View { + VStack(spacing: 20) { + Text("Pourquoi cette étape ?") + .font(.title) + Text(""" + Pour terminer la préparation de votre tournoi et pouvoir commencer à convoquer vos joueurs, vous devez inscrire les paires que vous avez préparé dans Padel Club sur le site beach-padel.app.fft.fr. + + Padel Club ne peut pas, pour l'instant, faire cette manipulation automatiquement. + + Par contre, vous pouvez exporter les paires que vous avez préparé en un simple fichier texte vous permettant ainsi d'accélérer un peu plus la saisie sur le site fédéral. + + Une fois vos que vos paires seront inscrites sur beach-padel.app.fft.fr, vous pourrez les importer à nouveau dans Padel Club en un instant, vous donnant accès aux emails et téléphones des joueurs dans le but de les convoquer. + + """) + .foregroundStyle(.secondary) + + + ShareLink(item: tournament.pasteDataForImporting()) { + HStack { + Spacer() + Text("Exporter les inscriptions") + Spacer() + } + } + .buttonStyle(.borderedProminent) + + + Button("J'ai compris") { + dismiss() + } + } + .padding([.leading, .trailing], 40) + } +} diff --git a/PadelClub/Views/Tournament/FileImportView.swift b/PadelClub/Views/Tournament/FileImportView.swift index ecdb624..05e9213 100644 --- a/PadelClub/Views/Tournament/FileImportView.swift +++ b/PadelClub/Views/Tournament/FileImportView.swift @@ -8,6 +8,7 @@ import SwiftUI struct FileImportView: View { + @EnvironmentObject var dataStore: DataStore @Environment(Tournament.self) var tournament: Tournament @Environment(\.dismiss) private var dismiss @@ -54,7 +55,7 @@ struct FileImportView: View { } } - if filteredTeams.isEmpty == false { + if filteredTeams.isEmpty == false && tournament.unsortedTeams().isEmpty == false { Section { ForEach(TeamImportStrategy.allCases, id: \.self) { strategy in LabeledContent { @@ -224,11 +225,26 @@ struct FileImportView: View { ToolbarItem(placement: .bottomBar) { Button { -// tournament.updateTournamentEntriesWith(teams: filteredTeams, viewContext: viewContext) -// save() - tournament.importTeams(filteredTeams) - + if selectedOptions.contains(.deleteBeforeImport) { // remove all previous teams + try? dataStore.teamRegistrations.delete(contentOfs: tournament.unsortedTeams()) + } + + if selectedOptions.contains(.notFoundAreWalkOut) { + let previousTeams = filteredTeams.compactMap({ $0.previousTeam }) + + let unfound = Set(tournament.unsortedTeams()).subtracting(Set(previousTeams)) + unfound.forEach { team in + team.resetPositions() + team.wildCardBracket = false + team.wildCardGroupStage = false + team.walkOut = true + } + + try? dataStore.teamRegistrations.addOrUpdate(contentOfs: unfound) + + } + tournament.importTeams(filteredTeams) dismiss() } label: { Text("Valider") @@ -262,38 +278,25 @@ struct FileImportView: View { } enum TeamImportStrategy: CaseIterable { - case keepPreviousData case notFoundAreWalkOut case deleteBeforeImport - case updatePosition - case updatePositionWithinBlock func titleLabel() -> String { switch self { - case .keepPreviousData: - "Gardez les données existantes" case .notFoundAreWalkOut: "Mettre les équipes manquantes WO" case .deleteBeforeImport: "Effacer avant d'importer" - case .updatePosition: - "Modifier les positions" - case .updatePositionWithinBlock: - "Modidier les positions par bloc" } } func descriptionLabel() -> String { switch self { - case .keepPreviousData: - "Si l'équipe déjà présente, garde la date d'inscription" case .notFoundAreWalkOut: "Si une équipe déjà présente n'est pas dans la nouvelle liste, elle sera mise à WO" case .deleteBeforeImport: - "Écrase les données précédentes avant d'importer" - case .updatePosition: - "Mets à jour les positions si changement de poids d'équipe" - case .updatePositionWithinBlock: - "Mets à jour les positions seulement au sein du tableau et des poules séparement" + "Supprime toutes les équipes avant d'importer" +// case .lockWeight: +// "Permets de déplacer les équipes avec leur nouveaux classements sans les déplacer entre les blocs (p500+)" } } } diff --git a/PadelClub/Views/Tournament/Screen/Components/UpdateSourceRankDateView.swift b/PadelClub/Views/Tournament/Screen/Components/UpdateSourceRankDateView.swift index 5f200c1..0a92146 100644 --- a/PadelClub/Views/Tournament/Screen/Components/UpdateSourceRankDateView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/UpdateSourceRankDateView.swift @@ -11,44 +11,50 @@ struct UpdateSourceRankDateView: View { @EnvironmentObject var dataStore: DataStore @Binding var currentRankSourceDate: Date? @Binding var confirmUpdateRank: Bool + @State private var forceRefreshLockWeight: Bool = false @State private var updatingRank = false var tournament: Tournament var body: some View { NavigationStack { List { - let suffix = (false ? "Les incriptions sont closes. Les équipes ne pourront pas être déplacé entre les poules et le tableau. Seule leur position au sein des poules et du tableau, respectivement, seront modifiée." : "Les inscriptions sont toujours ouvertes. Les équipes pourront être déplacé entre les poules et le tableau.") + let suffix = (tournament.inscriptionClosed() ? "Les incriptions sont closes. Les équipes ne pourront pas être déplacé entre les poules et le tableau. Seule leur position au sein des poules et du tableau, respectivement, seront modifiée." : "Les inscriptions sont toujours ouvertes. Les équipes pourront être déplacé entre les poules et le tableau.") Section { - Text("Vous êtes sur le point de mettre à jour les rangs des équipes, cela affectera leur position." + "\n" + suffix) + Text("Vous êtes sur le point de mettre à jour les rangs des équipes." + "\n" + suffix) + } + + if tournament.inscriptionClosed() { + Section { + Toggle(isOn: $forceRefreshLockWeight) { + Text("Ne pas en tenir compte") + } + } } RowButtonView(title: "Valider") { updatingRank = true - //buildMoveArray() Task { do { try await tournament.updateRank(to: currentRankSourceDate) await MainActor.run { - if tournament.state() == .build { - //manageEntriesMovement() - } else { - //save() - tournament.unsortedPlayers().forEach { player in - player.setWeight(in: tournament) - } - - try? dataStore.playerRegistrations.addOrUpdate(contentOfs: tournament.unsortedPlayers()) - - tournament.unsortedTeams().forEach { team in - team.setWeight(from: team.players()) + tournament.unsortedPlayers().forEach { player in + player.setWeight(in: tournament) + } + + try? dataStore.playerRegistrations.addOrUpdate(contentOfs: tournament.unsortedPlayers()) + + tournament.unsortedTeams().forEach { team in + team.setWeight(from: team.players()) + if forceRefreshLockWeight { + team.lockWeight = team.weight } - - try? dataStore.teamRegistrations.addOrUpdate(contentOfs: tournament.unsortedTeams()) - - try? dataStore.tournaments.addOrUpdate(instance: tournament) } + try? dataStore.teamRegistrations.addOrUpdate(contentOfs: tournament.unsortedTeams()) + + try? dataStore.tournaments.addOrUpdate(instance: tournament) + updatingRank = false confirmUpdateRank = false } diff --git a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift index 98e7903..c6cdbda 100644 --- a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift @@ -22,13 +22,13 @@ struct InscriptionManagerView: View { @State private var presentPlayerSearch: Bool = false @State private var presentPlayerCreation: Bool = false @State private var presentImportView: Bool = false + @State private var isLearningMore: Bool = false @State private var createdPlayers: Set = Set() @State private var createdPlayerIds: Set = Set() @State private var editedTeam: TeamRegistration? @State private var pasteString: String? @State private var currentRankSourceDate: Date? @State private var confirmUpdateRank = false - @State private var updatingRank = false @State private var selectionSearchField: String? let slideToDeleteTip = SlideToDeleteTip() @@ -38,10 +38,13 @@ struct InscriptionManagerView: View { let searchTip = InscriptionManagerSearchInputTip() let createTip = InscriptionManagerCreateInputTip() let rankUpdateTip = InscriptionManagerRankUpdateTip() + let padelBeachExportTip = PadelBeachExportTip() + let padelBeachImportTip = PadelBeachImportTip() let categoryOption: PlayerFilterOption let filterable: Bool - + let dates = Set(SourceFileManager.shared.allFilesSortedByDate(true).map({ $0.dateFromPath })).sorted().reversed() + init(tournament: Tournament) { self.tournament = tournament _currentRankSourceDate = State(wrappedValue: tournament.rankSourceDate) @@ -54,290 +57,11 @@ struct InscriptionManagerView: View { filterable = true } } - - private func _searchSource() -> String? { - selectionSearchField ?? pasteString - } - - private func _pastePredicate(pasteField: String, mostRecentDate: Date?) -> NSPredicate? { - let text = pasteField.canonicalVersion - - let nameComponents = text.components(separatedBy: .whitespacesAndNewlines).compactMap { $0.isEmpty ? nil : $0 }.filter({ $0 != "de" && $0 != "la" && $0 != "le" && $0.count > 1 }) - var andPredicates = [NSPredicate]() - var orPredicates = [NSPredicate]() - //self.wordsCount = nameComponents.count - - - if _filterOption() == .male { - andPredicates.append(NSPredicate(format: "male == YES")) - } else if _filterOption() == .female { - andPredicates.append(NSPredicate(format: "male == NO")) - } - - if let mostRecentDate { - andPredicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg)) - } - - if nameComponents.count > 1 { - orPredicates = nameComponents.pairs().map { - return NSPredicate(format: "(firstName contains[cd] %@ AND lastName contains[cd] %@) OR (firstName contains[cd] %@ AND lastName contains[cd] %@)", $0, $1, $1, $0) } - } else { - orPredicates = nameComponents.map { NSPredicate(format: "firstName contains[cd] %@ OR lastName contains[cd] %@", $0,$0) } - } - - let matches = text.licencesFound() - let licensesPredicates = matches.map { NSPredicate(format: "license contains[cd] %@", $0) } - orPredicates = orPredicates + licensesPredicates - - var predicate = NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates) - - if orPredicates.isEmpty == false { - predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, NSCompoundPredicate(orPredicateWithSubpredicates: orPredicates)]) - } - - return predicate - } - - private func _currentSelection() -> Set { - var currentSelection = Set() - createdPlayerIds.compactMap { id in - fetchPlayers.first(where: { id == $0.license }) - }.forEach { player in - let player = PlayerRegistration(importedPlayer: player) - player.setWeight(in: tournament) - currentSelection.insert(player) - } - - createdPlayerIds.compactMap { id in - createdPlayers.first(where: { id == $0.id }) - }.forEach { - currentSelection.insert($0) - } - return currentSelection - } - - private func _createTeam() { - tournament.addTeam(_currentSelection()) - createdPlayers.removeAll() - createdPlayerIds.removeAll() - pasteString = nil - } - - private func _updateTeam() { - editedTeam?.updatePlayers(_currentSelection()) - createdPlayers.removeAll() - createdPlayerIds.removeAll() - pasteString = nil - editedTeam = nil - } - - private func _buildingTeamView() -> some View { - List(selection: $createdPlayerIds) { - Section { - ForEach(createdPlayerIds.sorted(), id: \.self) { id in - if let p = createdPlayers.first(where: { $0.id == id }) { - PlayerView(player: p).tag(p.id) - } - if let p = fetchPlayers.first(where: { $0.license == id }) { - ImportedPlayerView(player: p).tag(p.license!) - } - } -// ForEach(createdPlayers.sorted(by: \.computedRank)) { player in -// PlayerView(player: player).tag(player.id) -// } - } - - if editedTeam == nil { - if createdPlayerIds.isEmpty { - RowButtonView(title: "Bloquer une place") { - _createTeam() - } - } else { - RowButtonView(title: "Ajouter l'équipe") { - _createTeam() - } - } - } else { - RowButtonView(title: "Modifier l'équipe") { - _updateTeam() - } - } - - if let pasteString { - - Section { - Text(pasteString) - } footer: { - HStack { - Text("contenu du presse-papier") - Spacer() - Button("effacer", role: .destructive) { - self.pasteString = nil - self.createdPlayers.removeAll() - self.createdPlayerIds.removeAll() - } - .buttonStyle(.borderless) - } - } - - if fetchPlayers.isEmpty { - ContentUnavailableView { - Label("Aucun résultat", systemImage: "person.2.slash") - } description: { - Text("Aucun joueur classé n'a été trouvé dans ce message.") - } actions: { - RowButtonView(title: "Créer un joueur non classé") { - presentPlayerCreation = true - } - - RowButtonView(title: "Effacer cette recherche") { - self.pasteString = nil - } - } - - } else { - Section { - ForEach(fetchPlayers.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) })) { player in - ImportedPlayerView(player: player).tag(player.license!) - } - } header: { - Text(fetchPlayers.count.formatted() + " résultat" + fetchPlayers.count.pluralSuffix) - } - } - } - } - .onReceive(fetchPlayers.publisher.count()) { _ in // <-- here - if let pasteString, count == 2 { - fetchPlayers.filter { $0.hitForSearch(pasteString) >= hitTarget }.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) }).forEach { player in - createdPlayerIds.insert(player.license!) - } - } - } - - .environment(\.editMode, Binding.constant(EditMode.active)) - } - - var count: Int { - return fetchPlayers.filter { $0.hitForSearch(pasteString ?? "") >= hitTarget }.count - } - - var hitTarget: Int { - if (pasteString?.matches(of: /[1-9][0-9]{5,7}/).count ?? 0) > 1 { - if fetchPlayers.filter({ $0.hitForSearch(pasteString ?? "") == 100 }).count == 2 { return 100 } - } else { - return 2 - } - return 1 - } - - private func _teamRegisteredView() -> some View { - List { - Section { - _rankHandlerView() - - let duplicates = tournament.duplicates() - DisclosureGroup { - if duplicates.isEmpty == false { - ForEach(duplicates) { player in - PlayerView(player: player) - } - } - } label: { - LabeledContent { - Text(duplicates.count.formatted()) - } label: { - Text("Doublons") - } - } - } header: { - Text("Informations") - } - - if tournament.tournamentCategory == .men && tournament.femalePlayers().isEmpty == false { - Section { - TipView(inscriptionManagerWomanRankTip) - .tipStyle(tint: nil) - } - } - - Section { - TipView(slideToDeleteTip) - .tipStyle(tint: nil) - } - - let unfilteredTeams = tournament.teams() - let teams = searchField.isEmpty ? unfilteredTeams : unfilteredTeams.filter({ $0.contains(searchField.canonicalVersion) }) - - if teams.isEmpty && searchField.isEmpty == false { - ContentUnavailableView { - Label("Aucun résultat", systemImage: "person.2.slash") - } description: { - Text("\(searchField) est introuvable dans les équipes inscrites.") - } actions: { - RowButtonView(title: "Modifier la recherche") { - searchField = "" - presentSearch = true - } - - RowButtonView(title: "Créer une équipe") { - Task { - await MainActor.run() { - fetchPlayers.nsPredicate = _pastePredicate(pasteField: searchField, mostRecentDate: tournament.rankSourceDate) - pasteString = searchField - } - } - } - - RowButtonView(title: "D'accord") { - searchField = "" - presentSearch = false - } - } - } - - ForEach(teams) { team in - Section { - TeamRowView(team: team) - } header: { - HStack { - Text("Équipe " + team.formattedSeed(in: unfilteredTeams)) - Spacer() - Text(team.weight.formatted()) - } - } footer: { - HStack { - Spacer() - Menu { - Button("Éditer") { - editedTeam = team - team.unsortedPlayers().forEach { player in - createdPlayers.insert(player) - createdPlayerIds.insert(player.id) - } - } - Divider() - Button(role: .destructive) { - try? dataStore.teamRegistrations.delete(instance: team) - } label: { - LabelDelete() - } - } label: { - LabelOptions().labelStyle(.titleOnly) - } - } - } - .headerProminence(.increased) - } - } - .searchable(text: $searchField, isPresented: $presentSearch, prompt: Text("Chercher parmi les équipes inscrites")) - .keyboardType(.alphabet) - .autocorrectionDisabled() - } var body: some View { VStack(spacing: 0) { _managementView() - if createdPlayerIds.isEmpty == false || pasteString != nil || editedTeam != nil { + if _isEditingTeam() { _buildingTeamView() } else if tournament.unsortedTeams().isEmpty { _inscriptionTipsView() @@ -345,6 +69,9 @@ struct InscriptionManagerView: View { _teamRegisteredView() } } + .sheet(isPresented: $isLearningMore) { + LearnMoreSheetView(tournament: tournament) + } .sheet(isPresented: $presentPlayerSearch, onDismiss: { selectionSearchField = nil }) { @@ -375,43 +102,46 @@ struct InscriptionManagerView: View { FileImportView(fileContent: nil) } } + .onChange(of: tournament.prioritizeClubMembers) { + _save() + } + .onChange(of: tournament.teamSorting) { + _save() + } .onChange(of: currentRankSourceDate) { -// if let currentRankSourceDate, tournament.currentRankSourceDate != currentRankSourceDate { -// confirmUpdateRank = true -// } - confirmUpdateRank = true + if let currentRankSourceDate, tournament.rankSourceDate != currentRankSourceDate { + confirmUpdateRank = true + } } .sheet(isPresented: $confirmUpdateRank, onDismiss: { currentRankSourceDate = tournament.rankSourceDate }) { UpdateSourceRankDateView(currentRankSourceDate: $currentRankSourceDate, confirmUpdateRank: $confirmUpdateRank, tournament: tournament) } - .toolbar { - if createdPlayerIds.isEmpty == false { + if _isEditingTeam() { ToolbarItem(placement: .cancellationAction) { Button("Annuler", role: .cancel) { + pasteString = nil createdPlayers.removeAll() createdPlayerIds.removeAll() } } - } - - if editedTeam == nil { + } else { ToolbarItem(placement: .navigationBarTrailing) { Menu { if tournament.inscriptionClosed() == false { Menu { - //sortingTypePickerView + _sortingTypePickerView() } label: { Text("Méthode de sélection") - Text(tournament.teamSortingType.localizedLabel()) + Text(tournament.teamSorting.localizedLabel()) } Divider() rankingDateSourcePickerView(showDateInLabel: true) - if tournament.teamSortingType == .inscriptionDate { + if tournament.teamSorting == .inscriptionDate { Divider() - //prioritizeClubMembersButton + _prioritizeClubMembersButton() } Divider() Button { @@ -425,19 +155,16 @@ struct InscriptionManagerView: View { Label("Clôturer", systemImage: "lock") } Divider() -// ShareLink(item: tournament.pasteDataForImporting) { -// Text("Exporter les paires") -// } - + ShareLink(item: tournament.pasteDataForImporting()) { + Text("Exporter les paires") + } Button { presentImportView = true } label: { Label("Importer beach-padel", systemImage: "square.and.arrow.down") } - if let url = URL(string: "beach-padel.app.fft.fr") { - Link(destination: url) { - Label("beach-padel.app.fft.fr", systemImage: "safari") - } + Link(destination: SourceFileManager.beachPadel) { + Label("beach-padel.app.fft.fr", systemImage: "safari") } } else { Button { @@ -457,12 +184,89 @@ struct InscriptionManagerView: View { } } } - .navigationBarBackButtonHidden(createdPlayerIds.isEmpty == false) - .toolbarBackground(.visible, for: .navigationBar) - .navigationTitle("Inscriptions") - .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(_isEditingTeam()) + .toolbarBackground(.visible, for: .navigationBar) + .navigationTitle("Inscriptions") + .navigationBarTitleDisplayMode(.inline) + } + + private func _isEditingTeam() -> Bool { + createdPlayerIds.isEmpty == false || editedTeam != nil || pasteString != nil + } + + private func _teamRegisteredView() -> some View { + List { + Section { + _rankHandlerView() + + let duplicates = tournament.duplicates() + DisclosureGroup { + if duplicates.isEmpty == false { + ForEach(duplicates) { player in + PlayerView(player: player) + } + } + } label: { + LabeledContent { + Text(duplicates.count.formatted()) + } label: { + Text("Doublons") + } + } + } header: { + Text("Informations") + } + + _relatedTips() + + let unfilteredTeams = tournament.sortedTeams() + let teams = searchField.isEmpty ? unfilteredTeams : unfilteredTeams.filter({ $0.contains(searchField.canonicalVersion) }) + + if teams.isEmpty && searchField.isEmpty == false { + ContentUnavailableView { + Label("Aucun résultat", systemImage: "person.2.slash") + } description: { + Text("\(searchField) est introuvable dans les équipes inscrites.") + } actions: { + RowButtonView(title: "Modifier la recherche") { + searchField = "" + presentSearch = true + } + + RowButtonView(title: "Créer une équipe") { + Task { + await MainActor.run() { + fetchPlayers.nsPredicate = _pastePredicate(pasteField: searchField, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable) + pasteString = searchField + } + } + } + + RowButtonView(title: "D'accord") { + searchField = "" + presentSearch = false + } + } + } + + ForEach(teams) { team in + let teamIndex = team.index(in: unfilteredTeams) + Section { + TeamRowView(team: team) + } header: { + _teamHeaderView(team, teamIndex: teamIndex) + } footer: { + _teamFooterView(team) + } + .headerProminence(.increased) + } + } + .searchable(text: $searchField, isPresented: $presentSearch, prompt: Text("Chercher parmi les équipes inscrites")) + .keyboardType(.alphabet) + .autocorrectionDisabled() } - + + @MainActor private func _managementView() -> some View { HStack { Button { @@ -481,12 +285,8 @@ struct InscriptionManagerView: View { PasteButton(payloadType: String.self) { strings in guard let first = strings.first else { return } - Task { - await MainActor.run() { - fetchPlayers.nsPredicate = _pastePredicate(pasteField: first, mostRecentDate: tournament.rankSourceDate) - pasteString = first - } - } + fetchPlayers.nsPredicate = _pastePredicate(pasteField: first, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable) + pasteString = first } Button { @@ -517,9 +317,6 @@ struct InscriptionManagerView: View { if currentRankSourceDate == nil { Text("inconnu").tag(nil as Date?) } - - let dates = Array(Set(SourceFileManager.shared.allFilesSortedByDate(tournament.tournamentCategory.rankingDataSourceMale).map({ $0.dateFromPath }))).sorted().reversed() - ForEach(dates, id: \.self) { date in Text(date.monthYearFormatted).tag(date as Date?) } @@ -562,13 +359,15 @@ struct InscriptionManagerView: View { } @ViewBuilder - func _inscriptionTipsView() -> some View { + private func _inscriptionTipsView() -> some View { List { Section { TipView(fileTip) { action in if action.id == "website" { + UIApplication.shared.open(SourceFileManager.beachPadel) } else if action.id == "add-team-file" { + presentImportView = true } } .tipStyle(tint: nil) @@ -609,7 +408,7 @@ struct InscriptionManagerView: View { } @ViewBuilder - func _rankHandlerView() -> some View { + private func _rankHandlerView() -> some View { if let mostRecentDate = SourceFileManager.shared.lastDataSourceDate(), let currentRankSourceDate, currentRankSourceDate < mostRecentDate, tournament.hasEnded() == false { Section { TipView(rankUpdateTip) { action in @@ -623,7 +422,359 @@ struct InscriptionManagerView: View { } } - func _save() { + @ViewBuilder + private func _relatedTips() -> some View { + if pasteString?.isEmpty == true + && createdPlayerIds.isEmpty + && tournament.unsortedTeams().count >= tournament.teamCount + && tournament.unsortedPlayers().filter({ $0.source == .beachPadel }).isEmpty { + Section { + TipView(padelBeachExportTip) { action in + if action.id == "more-info-export" { + isLearningMore = true + } + if action.id == "padel-beach" { + UIApplication.shared.open(SourceFileManager.beachPadel) + } + } + .tipStyle(tint: nil) + } + Section { + TipView(padelBeachImportTip) { action in + if action.id == "more-info-import" { + presentImportView = true + } + } + .tipStyle(tint: nil) + } + } + + + if tournament.tournamentCategory == .men && tournament.femalePlayers().isEmpty == false { + Section { + TipView(inscriptionManagerWomanRankTip) + .tipStyle(tint: nil) + } + } + + Section { + TipView(slideToDeleteTip) + .tipStyle(tint: nil) + } + } + + private func _searchSource() -> String? { + selectionSearchField ?? pasteString + } + + private func _pastePredicate(pasteField: String, mostRecentDate: Date?) -> NSPredicate? { + let text = pasteField.canonicalVersion + + let nameComponents = text.components(separatedBy: .whitespacesAndNewlines).compactMap { $0.isEmpty ? nil : $0 }.filter({ $0 != "de" && $0 != "la" && $0 != "le" && $0.count > 1 }) + var andPredicates = [NSPredicate]() + var orPredicates = [NSPredicate]() + //self.wordsCount = nameComponents.count + + + if _filterOption() == .male { + andPredicates.append(NSPredicate(format: "male == YES")) + } else if _filterOption() == .female { + andPredicates.append(NSPredicate(format: "male == NO")) + } + + if let mostRecentDate { + andPredicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg)) + } + + if nameComponents.count > 1 { + orPredicates = nameComponents.pairs().map { + return NSPredicate(format: "(firstName contains[cd] %@ AND lastName contains[cd] %@) OR (firstName contains[cd] %@ AND lastName contains[cd] %@)", $0, $1, $1, $0) } + } else { + orPredicates = nameComponents.map { NSPredicate(format: "firstName contains[cd] %@ OR lastName contains[cd] %@", $0,$0) } + } + + let matches = text.licencesFound() + let licensesPredicates = matches.map { NSPredicate(format: "license contains[cd] %@", $0) } + orPredicates = orPredicates + licensesPredicates + + var predicate = NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates) + + if orPredicates.isEmpty == false { + predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, NSCompoundPredicate(orPredicateWithSubpredicates: orPredicates)]) + } + + return predicate + } + + private func _currentSelection() -> Set { + var currentSelection = Set() + createdPlayerIds.compactMap { id in + fetchPlayers.first(where: { id == $0.license }) + }.forEach { player in + let player = PlayerRegistration(importedPlayer: player) + player.setWeight(in: tournament) + currentSelection.insert(player) + } + + createdPlayerIds.compactMap { id in + createdPlayers.first(where: { id == $0.id }) + }.forEach { + currentSelection.insert($0) + } + return currentSelection + } + + private func _createTeam() { + let players = _currentSelection() + let team = tournament.addTeam(players) + try? dataStore.teamRegistrations.addOrUpdate(instance: team) + try? dataStore.playerRegistrations.addOrUpdate(contentOfs: players) + + createdPlayers.removeAll() + createdPlayerIds.removeAll() + pasteString = nil + } + + private func _updateTeam() { + guard let editedTeam else { return } + let players = _currentSelection() + editedTeam.updatePlayers(players) + try? dataStore.teamRegistrations.addOrUpdate(instance: editedTeam) + try? dataStore.playerRegistrations.addOrUpdate(contentOfs: players) + createdPlayers.removeAll() + createdPlayerIds.removeAll() + pasteString = nil + self.editedTeam = nil + } + + private func _buildingTeamView() -> some View { + List(selection: $createdPlayerIds) { + Section { + ForEach(createdPlayerIds.sorted(), id: \.self) { id in + if let p = createdPlayers.first(where: { $0.id == id }) { + PlayerView(player: p).tag(p.id) + } + if let p = fetchPlayers.first(where: { $0.license == id }) { + ImportedPlayerView(player: p).tag(p.license!) + } + } + } + + if editedTeam == nil { + if createdPlayerIds.isEmpty { + RowButtonView(title: "Bloquer une place") { + _createTeam() + } + } else { + RowButtonView(title: "Ajouter l'équipe") { + _createTeam() + } + } + } else { + RowButtonView(title: "Modifier l'équipe") { + _updateTeam() + } + } + + if let pasteString { + + Section { + Text(pasteString) + } footer: { + HStack { + Text("contenu du presse-papier") + Spacer() + Button("effacer", role: .destructive) { + self.pasteString = nil + self.createdPlayers.removeAll() + self.createdPlayerIds.removeAll() + } + .buttonStyle(.borderless) + } + } + + if fetchPlayers.isEmpty { + ContentUnavailableView { + Label("Aucun résultat", systemImage: "person.2.slash") + } description: { + Text("Aucun joueur classé n'a été trouvé dans ce message.") + } actions: { + RowButtonView(title: "Créer un joueur non classé") { + presentPlayerCreation = true + } + + RowButtonView(title: "Effacer cette recherche") { + self.pasteString = nil + } + } + + } else { + Section { + ForEach(fetchPlayers.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) })) { player in + ImportedPlayerView(player: player).tag(player.license!) + } + } header: { + Text(fetchPlayers.count.formatted() + " résultat" + fetchPlayers.count.pluralSuffix) + } + } + } + } + .onReceive(fetchPlayers.publisher.count()) { _ in // <-- here + if let pasteString, count == 2 { + fetchPlayers.filter { $0.hitForSearch(pasteString) >= hitTarget }.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) }).forEach { player in + createdPlayerIds.insert(player.license!) + } + } + } + + .environment(\.editMode, Binding.constant(EditMode.active)) + } + + private var count: Int { + return fetchPlayers.filter { $0.hitForSearch(pasteString ?? "") >= hitTarget }.count + } + + private var hitTarget: Int { + if (pasteString?.matches(of: /[1-9][0-9]{5,7}/).count ?? 0) > 1 { + if fetchPlayers.filter({ $0.hitForSearch(pasteString ?? "") == 100 }).count == 2 { return 100 } + } else { + return 2 + } + return 1 + } + + @ViewBuilder + private func _sortingTypePickerView() -> some View { + @Bindable var tournament = tournament + Picker(selection: $tournament.teamSorting) { + ForEach(TeamSortingType.allCases) { + Text($0.localizedLabel()).tag($0) + } + } label: { + } + } + + @ViewBuilder + private func _prioritizeClubMembersButton() -> some View { + @Bindable var tournament = tournament + if let federalClub = tournament.club() { + Menu { + Picker(selection: $tournament.prioritizeClubMembers) { + Text("Oui").tag(true) + Text("Non").tag(false) + } label: { + + } + .labelsHidden() + } label: { + Text("Membres prioritaires") + Text(federalClub.acronym) + } + Divider() + } else if let event = tournament.eventObject { + NavigationLink { + ClubSearchView() + } label: { + Text("Identifier le club") + } + Divider() + } + } + + private func _teamHeaderView(_ team: TeamRegistration, teamIndex: Int?) -> some View { + HStack { + if let teamIndex { + Text("#" + (teamIndex + 1).formatted()) + } + + if team.unsortedPlayers().isEmpty == false { + Text(team.weight.formatted()) + } + if team.isWildCard() { + Text("wildcard").italic().font(.caption) + } + Spacer() + if team.walkOut { + Text("WO") + } else if let teamIndex { + Text(tournament.cutLabel(index: teamIndex)) + } + } + } + + private func _teamFooterView(_ team: TeamRegistration) -> some View { + HStack { + if let formattedRegistrationDate = team.formattedInscriptionDate() { + Text(formattedRegistrationDate).font(.caption).foregroundStyle(.secondary) + } + Spacer() + _teamMenuOptionView(team) + } + } + private func _teamMenuOptionView(_ team: TeamRegistration) -> some View { + Menu { + Section { + Button("Éditer les joueurs") { + editedTeam = team + team.unsortedPlayers().forEach { player in + createdPlayers.insert(player) + createdPlayerIds.insert(player.id) + } + } + Divider() + + Toggle(isOn: .init(get: { + return team.wildCardBracket + }, set: { value in + team.resetPositions() + team.wildCardGroupStage = false + team.walkOut = false + team.wildCardBracket = value + try? dataStore.teamRegistrations.addOrUpdate(instance: team) + })) { + Label("Wildcard Tableau", systemImage: team.wildCardBracket ? "circle.inset.filled" : "circle") + } + + Toggle(isOn: .init(get: { + return team.wildCardGroupStage + }, set: { value in + team.resetPositions() + team.wildCardBracket = false + team.walkOut = false + team.wildCardGroupStage = value + try? dataStore.teamRegistrations.addOrUpdate(instance: team) + })) { + Label("Wildcard Poule", systemImage: team.wildCardGroupStage ? "circle.inset.filled" : "circle") + } + + Divider() + Toggle(isOn: .init(get: { + return team.walkOut + }, set: { value in + team.resetPositions() + team.wildCardBracket = false + team.wildCardGroupStage = false + team.walkOut = value + try? dataStore.teamRegistrations.addOrUpdate(instance: team) + })) { + Label("WO", systemImage: team.walkOut ? "circle.inset.filled" : "circle") + } + Divider() + Button(role: .destructive) { + try? dataStore.teamRegistrations.delete(instance: team) + } label: { + LabelDelete() + } + } header: { + Text(team.teamLabel(.short)) + } + } label: { + LabelOptions().labelStyle(.titleOnly) + .font(.caption) + } + } + + private func _save() { try? dataStore.tournaments.addOrUpdate(instance: tournament) } }