diff --git a/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift b/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift index b7679f3..ba05ab0 100644 --- a/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift +++ b/PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift @@ -74,7 +74,8 @@ extension ImportedPlayer: PlayerHolder { firstName?.localizedCaseInsensitiveContains(searchField) == true || lastName?.localizedCaseInsensitiveContains(searchField) == true } - func hitForSearch(_ searchText: String) -> Int { + func hitForSearch(_ searchText: String?) -> Int { + guard let searchText else { return 0 } var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current) trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ") trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .symbols, replacementString: " ") diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index cffdbd1..89008b0 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -1009,15 +1009,39 @@ defer { return [] } return players.filter { player in - if player.rank == nil { return false } - if player.computedRank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge) { - return true - } else { - return false - } + return isPlayerRankInadequate(player: player) + } + } + + func isPlayerRankInadequate(player: PlayerHolder) -> Bool { + guard let rank = player.getRank() else { return false } + let _rank = player.male ? rank : rank + PlayerRegistration.addon(for: rank, manMax: maleUnrankedValue ?? 0, womanMax: femaleUnrankedValue ?? 0) + if _rank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge) { + return true + } else { + return false + } + } + + func ageInadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] { + if startDate.isInCurrentYear() == false { + return [] + } + return players.filter { player in + return isPlayerAgeInadequate(player: player) + } + } + + func isPlayerAgeInadequate(player: PlayerHolder) -> Bool { + guard let computedAge = player.computedAge else { return false } + if federalTournamentAge.isAgeValid(age: computedAge) == false { + return true + } else { + return false } } + func mandatoryRegistrationCloseDate() -> Date? { switch tournamentLevel { case .p500, .p1000, .p1500, .p2000: diff --git a/PadelClub/Extensions/Calendar+Extensions.swift b/PadelClub/Extensions/Calendar+Extensions.swift index 47971b5..bc7861a 100644 --- a/PadelClub/Extensions/Calendar+Extensions.swift +++ b/PadelClub/Extensions/Calendar+Extensions.swift @@ -30,8 +30,8 @@ extension Calendar { let currentYear = component(.year, from: currentDate) // Define the date components for 1st September and 31st December of the current year - var septemberFirstComponents = DateComponents(year: currentYear, month: 9, day: 1) - var decemberThirtyFirstComponents = DateComponents(year: currentYear, month: 12, day: 31) + let septemberFirstComponents = DateComponents(year: currentYear, month: 9, day: 1) + let decemberThirtyFirstComponents = DateComponents(year: currentYear, month: 12, day: 31) // Get the actual dates for 1st September and 31st December let septemberFirst = date(from: septemberFirstComponents)! diff --git a/PadelClub/Utils/PadelRule.swift b/PadelClub/Utils/PadelRule.swift index de58eb2..801276f 100644 --- a/PadelClub/Utils/PadelRule.swift +++ b/PadelClub/Utils/PadelRule.swift @@ -276,6 +276,28 @@ enum FederalTournamentAge: Int, Hashable, Codable, CaseIterable, Identifiable { var tournamentDescriptionLabel: String { return localizedLabel() } + + func isAgeValid(age: Int?) -> Bool { + guard let age else { return true } + switch self { + case .unlisted: + return true + case .a11_12: + return age < 13 + case .a13_14: + return age < 15 + case .a15_16: + return age < 17 + case .a17_18: + return age < 19 + case .senior: + return age >= 11 + case .a45: + return age >= 45 + case .a55: + return age >= 55 + } + } } enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable { diff --git a/PadelClub/Views/Navigation/Agenda/ActivityView.swift b/PadelClub/Views/Navigation/Agenda/ActivityView.swift index b7a243f..58eb1fb 100644 --- a/PadelClub/Views/Navigation/Agenda/ActivityView.swift +++ b/PadelClub/Views/Navigation/Agenda/ActivityView.swift @@ -23,9 +23,10 @@ struct ActivityView: View { @State private var presentClubSearchView: Bool = false @State private var quickAccessScreen: QuickAccessScreen? = nil @State private var displaySearchView: Bool = false + @State private var pasteString: String? = nil enum QuickAccessScreen : Identifiable, Hashable { - case inscription(pasteString: String) + case inscription var id: String { switch self { @@ -75,13 +76,29 @@ struct ActivityView: View { @ViewBuilder private func _pasteView() -> some View { - PasteButton(payloadType: String.self) { strings in - guard let first = strings.first else { return } - quickAccessScreen = .inscription(pasteString: first) + Button { + quickAccessScreen = .inscription + } label: { + Image(systemName: "person.crop.circle.badge.plus") + .resizable() + .scaledToFit() + .frame(minHeight: 32) } - .foregroundStyle(.master) - .labelStyle(.iconOnly) - .buttonBorderShape(.capsule) + .accessibilityLabel("Ajouter une équipe") + +// if pasteButtonIsDisplayed == nil || pasteButtonIsDisplayed == true { +// PasteButton(payloadType: String.self) { strings in +// let first = strings.first ?? "aucun texte" +// quickAccessScreen = .inscription(pasteString: first) +// } +// .foregroundStyle(.master) +// .labelStyle(.iconOnly) +// .buttonBorderShape(.capsule) +// .onAppear { +// pasteButtonIsDisplayed = true +// } +// } else if let pasteButtonIsDisplayed, pasteButtonIsDisplayed == false { +// } } var body: some View { @@ -189,6 +206,10 @@ struct ActivityView: View { .navigationDestination(for: Tournament.self) { tournament in TournamentView(tournament: tournament) } +// .onDisappear(perform: { +// pasteButtonIsDisplayed = nil +// print("disappearing", "pasteButtonIsDisplayed", pasteButtonIsDisplayed) +// }) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Button { @@ -291,28 +312,41 @@ struct ActivityView: View { } .sheet(item: $quickAccessScreen) { screen in switch screen { - case .inscription(let pasteString): + case .inscription: NavigationStack { List { - Section { - Text(pasteString) - } header: { - Text("Contenu du presse-papier") + + if let pasteString { + Section { + Text(pasteString) + .frame(maxWidth: .infinity) + .overlay { + if pasteString.isEmpty { + Text("Le presse-papier est vide") + .foregroundStyle(.secondary) + .italic() + } + } + } header: { + Text("Contenu du presse-papier") + } } - + Section { ForEach(getRunningTournaments()) { tournament in NavigationLink { AddTeamView(tournament: tournament, pasteString: pasteString, editedTeam: nil) } label: { - VStack(alignment: .leading) { + LabeledContent { + Text(tournament.unsortedTeamsWithoutWO().count.formatted()) + } label: { Text(tournament.tournamentTitle()) - Text(tournament.formattedDate()).foregroundStyle(.secondary) + Text(tournament.formattedDate()) } } } } header: { - Text("À coller dans la liste d'inscription") + Text("Ajouter à la liste d'inscription") } } .toolbar { @@ -321,6 +355,26 @@ struct ActivityView: View { self.quickAccessScreen = nil } } + + ToolbarItem(placement: .topBarTrailing) { + Button { + pasteString = UIPasteboard.general.string ?? "" + } label: { + Label("Coller", systemImage: "doc.on.clipboard").labelStyle(.iconOnly) + } + .foregroundStyle(.master) + .labelStyle(.iconOnly) + .buttonBorderShape(.capsule) + } + + ToolbarItem(placement: .bottomBar) { + PasteButton(payloadType: String.self) { strings in + pasteString = strings.first ?? "" + } + .foregroundStyle(.master) + .labelStyle(.titleAndIcon) + .buttonBorderShape(.capsule) + } } .navigationTitle("Choix du tournoi") .navigationBarTitleDisplayMode(.inline) diff --git a/PadelClub/Views/Tournament/Screen/AddTeamView.swift b/PadelClub/Views/Tournament/Screen/AddTeamView.swift index ca7ad1e..e7d65b1 100644 --- a/PadelClub/Views/Tournament/Screen/AddTeamView.swift +++ b/PadelClub/Views/Tournament/Screen/AddTeamView.swift @@ -42,7 +42,13 @@ struct AddTeamView: View { @State private var confirmHomonym: Bool = false @State private var editableTextField: String = "" @State private var textHeight: CGFloat = 100 // Default height - + @State private var hitsForSearch: [Int: Int] = [:] + @State private var searchForHit: Int = 0 + @State private var displayWarningNotEnoughCharacter: Bool = false + @State private var testMessageIndex: Int = 0 + + let filterLimit : Int = 1000 + var tournamentStore: TournamentStore { return self.tournament.tournamentStore } @@ -74,7 +80,7 @@ struct AddTeamView: View { } var body: some View { - if pasteString != nil, fetchPlayers.isEmpty == false { + if let pasteString, pasteString.isEmpty == false, fetchPlayers.isEmpty == false { computedBody .searchable(text: $searchField, placement: .navigationBarDrawer(displayMode: .always), prompt: Text("Chercher dans les résultats")) } else { @@ -86,14 +92,27 @@ struct AddTeamView: View { List(selection: $createdPlayerIds) { _buildingTeamView() } - .onReceive(fetchPlayers.publisher.count()) { _ in // <-- here - if let pasteString, count == 2, autoSelect == true { - fetchPlayers.filter { $0.hitForSearch(pasteString) >= hitTarget }.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) }).forEach { player in + .onReceive(fetchPlayers.publisher.count()) { receivedCount in // <-- here + if receivedCount < filterLimit, let pasteString, pasteString.isEmpty == false, count == 2, autoSelect == true { + fetchPlayers.filter { hitForSearch($0, pasteString) >= hitTarget }.sorted(by: { hitForSearch($0, pasteString) > hitForSearch($1, pasteString) }).forEach { player in createdPlayerIds.insert(player.license!) } autoSelect = false } } + .overlay(alignment: .bottom) { + if displayWarningNotEnoughCharacter { + Text("2 lettres mininum") + .toastFormatted() + .animation(.easeInOut(duration: 2.0), value: displayWarningNotEnoughCharacter) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + displayWarningNotEnoughCharacter = false + } + } + } + } + .alert("Présence d'homonyme", isPresented: $confirmHomonym) { Button("Créer l'équipe quand même") { _createTeam(checkDuplicates: false, checkHomonym: false) @@ -157,7 +176,7 @@ struct AddTeamView: View { if pasteString == nil { ToolbarItem(placement: .bottomBar) { PasteButton(payloadType: String.self) { strings in - guard let first = strings.first else { return } + let first = strings.first ?? "" handlePasteString(first) } .foregroundStyle(.master) @@ -165,6 +184,26 @@ struct AddTeamView: View { .buttonBorderShape(.capsule) } } + + ToolbarItem(placement: .topBarTrailing) { + Button { + let generalString = UIPasteboard.general.string ?? "" + + #if targetEnvironment(simulator) + let s = testMessages[testMessageIndex % testMessages.count] + handlePasteString(s) + testMessageIndex += 1 + #else + handlePasteString(generalString) + #endif + } label: { + Label("Coller", systemImage: "doc.on.clipboard").labelStyle(.iconOnly) + } + .foregroundStyle(.master) + .labelStyle(.iconOnly) + .buttonBorderShape(.capsule) + } + } .navigationBarBackButtonHidden(true) .toolbarBackground(.visible, for: .navigationBar) @@ -365,8 +404,12 @@ struct AddTeamView: View { } Spacer() Button("Chercher") { - self.handlePasteString(editableTextField) - self.focusedField = nil + if editableTextField.count > 1 { + self.handlePasteString(editableTextField) + self.focusedField = nil + } else { + self.displayWarningNotEnoughCharacter = true + } } .buttonStyle(.bordered) } @@ -393,7 +436,11 @@ struct AddTeamView: View { if let p = createdPlayers.first(where: { $0.id == id }) { VStack(alignment: .leading, spacing: 0) { if let player = unsortedPlayers.first(where: { ($0.licenceId == p.licenceId && $0.licenceId != nil) }), editedTeam?.includes(player: player) == false { - Text("Déjà inscrit !!").foregroundStyle(.logoRed).bold() + Text("Déjà inscrit !").foregroundStyle(.logoRed).bold() + } else if tournament.isPlayerAgeInadequate(player: p) { + Text("Âge invalide !").foregroundStyle(.logoRed).bold() + } else if tournament.isPlayerRankInadequate(player: p) { + Text("Trop bien classé !").foregroundStyle(.logoRed).bold() } PlayerView(player: p).tag(p.id) .environment(tournament) @@ -401,8 +448,12 @@ struct AddTeamView: View { } if let p = fetchPlayers.first(where: { $0.license == id }) { VStack(alignment: .leading, spacing: 0) { - if pasteString != nil, unsortedPlayers.first(where: { $0.licenceId == p.license }) != nil { + if let pasteString, pasteString.isEmpty == false, unsortedPlayers.first(where: { $0.licenceId == p.license }) != nil { Text("Déjà inscrit !").foregroundStyle(.logoRed).bold() + } else if tournament.isPlayerAgeInadequate(player: p) { + Text("Âge invalide !").foregroundStyle(.logoRed).bold() + } else if tournament.isPlayerRankInadequate(player: p) { + Text("Trop bien classé !").foregroundStyle(.logoRed).bold() } ImportedPlayerView(player: p).tag(p.license!) } @@ -454,8 +505,8 @@ struct AddTeamView: View { } - if let pasteString { - let sortedPlayers = fetchPlayers.filter({ $0.contains(searchField) || searchField.isEmpty }) + if let pasteString, pasteString.isEmpty == false { + let sortedPlayers = _searchFilteredPlayers() if sortedPlayers.isEmpty { ContentUnavailableView { @@ -478,20 +529,44 @@ struct AddTeamView: View { } } else { - _listOfPlayers(pasteString: pasteString) + _listOfPlayers(searchFilteredPlayers: sortedPlayers, pasteString: pasteString) } } else { _managementView() } } + @MainActor + func hitForSearch(_ ip: ImportedPlayer, _ pasteString: String?) -> Int { + guard let pasteString else { return 0 } + let _searchForHit = pasteString.hashValue + + if searchForHit != _searchForHit { + DispatchQueue.main.async { + searchForHit = _searchForHit + hitsForSearch = [:] + } + } + + let value = hitsForSearch[ip.id.hashValue] + if let value { + return value + } else { + let hit = ip.hitForSearch(pasteString) + DispatchQueue.main.async { + hitsForSearch[ip.id.hashValue] = hit + } + return hit + } + } + private var count: Int { - return fetchPlayers.filter { $0.hitForSearch(pasteString ?? "") >= hitTarget }.count + return fetchPlayers.filter { hitForSearch($0, 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 } + if fetchPlayers.filter({ hitForSearch($0, pasteString) == 100 }).count == 2 { return 100 } } else { return 2 } @@ -506,24 +581,22 @@ struct AddTeamView: View { } } + @MainActor private func handlePasteString(_ first: String) { - Task { - await MainActor.run { - fetchPlayers.nsPredicate = SearchViewModel.pastePredicate(pasteField: first, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable, filterOption: _filterOption()) - fetchPlayers.nsSortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)] - pasteString = first - editableTextField = first - textHeight = Self._calculateHeight(text: first) - autoSelect = true - } + if first.isEmpty == false { + fetchPlayers.nsPredicate = SearchViewModel.pastePredicate(pasteField: first, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable, filterOption: _filterOption()) + fetchPlayers.nsSortDescriptors = [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)] + autoSelect = true } - + pasteString = first + editableTextField = first + textHeight = Self._calculateHeight(text: first) } @ViewBuilder - private func _listOfPlayers(pasteString: String) -> some View { - let sortedPlayers = fetchPlayers.filter({ $0.contains(searchField) || searchField.isEmpty }).sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) }) + private func _listOfPlayers(searchFilteredPlayers: [ImportedPlayer], pasteString: String) -> some View { + let sortedPlayers = _sortedPlayers(searchFilteredPlayers: searchFilteredPlayers, pasteString: pasteString) Section { ForEach(sortedPlayers) { player in @@ -535,4 +608,50 @@ struct AddTeamView: View { } } + + private func _searchFilteredPlayers() -> [ImportedPlayer] { + if searchField.isEmpty { + return Array(fetchPlayers) + } else { + return fetchPlayers.filter({ $0.contains(searchField) }) + } + } + + private func _sortedPlayers(searchFilteredPlayers: [ImportedPlayer], pasteString: String) -> [ImportedPlayer] { + if searchFilteredPlayers.count < filterLimit { + return searchFilteredPlayers.sorted(by: { hitForSearch($0, pasteString) > hitForSearch($1, pasteString) }) + } else { + return searchFilteredPlayers + } + } } + +let testMessages = [ + "Anthony dovetta ( 3620578 K )et christophe capeau ( 4666443v)", +""" +ok merci, il s'agit de : +Olivier Seguin - licence 5033439 +JPascal Bondierlange - licence : +6508359 С +Cordialement +""", +""" +Bonsoir Lise, peux tu nous inscrire pour le 250 hommes du 15 au 17 novembre ? +Paires DESCHAMPS/PARDO. En te remerciant. Bonne soirée +Franck +""", +""" +Coucou inscription pour le tournoi du 11 / +12 octobre +Dumoutier/ Liagre Charlotte +Merci de ta confirmation" +""", +""" +Anthony Contet 6081758f +Tullou Benjamin 8990867f +""", +""" +Sms Julien La Croix +33622886688 +Salut Raz, c'est ! Ju Lacroix J'espère que tu vas bien depuis le temps! Est-ce que tu peux nous inscrire au 1000 de Bandol avec Derek Gerson stp? +""" +] diff --git a/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift b/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift index 2114d81..4161e47 100644 --- a/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/InscriptionInfoView.swift @@ -18,6 +18,7 @@ struct InscriptionInfoView: View { @State private var duplicates : [PlayerRegistration] = [] @State private var problematicPlayers : [PlayerRegistration] = [] @State private var inadequatePlayers : [PlayerRegistration] = [] + @State private var ageInadequatePlayers : [PlayerRegistration] = [] @State private var playersWithoutValidLicense : [PlayerRegistration] = [] @State private var entriesFromBeachPadel : [TeamRegistration] = [] @State private var playersMissing : [TeamRegistration] = [] @@ -177,6 +178,23 @@ struct InscriptionInfoView: View { Text("Il s'agit des joueurs ou joueuses dont le rang est inférieur à la limite fédérale.") } + Section { + DisclosureGroup { + ForEach(ageInadequatePlayers) { player in + ImportedPlayerView(player: player) + } + } label: { + LabeledContent { + Text(ageInadequatePlayers.count.formatted()) + } label: { + Text("Joueurs trop jeunes ou trop âgés") + } + } + .listRowView(color: .logoRed) + } footer: { + Text("Il s'agit des joueurs ou joueuses dont l'âge sportif est inférieur ou supérieur à la limite fédérale.") + } + Section { DisclosureGroup { ForEach(playersWithoutValidLicense) { @@ -228,6 +246,7 @@ struct InscriptionInfoView: View { homonyms = tournament.homonyms(in: players) problematicPlayers = players.filter({ $0.sex == nil }) inadequatePlayers = tournament.inadequatePlayers(in: players) + ageInadequatePlayers = tournament.ageInadequatePlayers(in: players) playersWithoutValidLicense = tournament.playersWithoutValidLicense(in: players) entriesFromBeachPadel = tournament.unsortedTeams().filter({ $0.isImported() }) playersMissing = selectedTeams.filter({ $0.unsortedPlayers().count < 2 }) diff --git a/PadelClubTests/ServerDataTests.swift b/PadelClubTests/ServerDataTests.swift index 7d19cb5..3947937 100644 --- a/PadelClubTests/ServerDataTests.swift +++ b/PadelClubTests/ServerDataTests.swift @@ -150,7 +150,7 @@ final class ServerDataTests: XCTestCase { return } - let groupStage = GroupStage(tournament: tournamentId, index: 2, size: 3, matchFormat: MatchFormat.nineGames, startDate: Date(), name: "Yeah!") + let groupStage = GroupStage(tournament: tournamentId, index: 2, size: 3, matchFormat: MatchFormat.nineGames, startDate: Date(), name: "Yeah!", step: 1) let gs: GroupStage = try await StoreCenter.main.service().post(groupStage) assert(gs.tournament == groupStage.tournament) @@ -159,6 +159,8 @@ final class ServerDataTests: XCTestCase { assert(gs.size == groupStage.size) assert(gs.matchFormat == groupStage.matchFormat) assert(gs.startDate != nil) + assert(gs.step == groupStage.step) + }