You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
PadelClub/PadelClub/ViewModel/SearchViewModel.swift

599 lines
22 KiB

//
// SearchViewModel.swift
// Padel Tournament
//
// Created by Razmig Sarkissian on 07/02/2024.
//
import SwiftUI
class DebouncableViewModel: ObservableObject {
@Published var debouncableText: String = ""
var debounceTrigger: Double = 0.15
}
class SearchViewModel: ObservableObject, Identifiable {
let id: UUID = UUID()
var allowSelection : Int = 0
var codeClub: String? = nil
var clubName: String? = nil
var ligueName: String? = nil
var showFemaleInMaleAssimilation: Bool = false
var hidePlayers: [String]?
@Published var debouncableText: String = ""
@Published var searchText: String = ""
@Published var task: DispatchWorkItem?
@Published var computedSearchText: String = ""
@Published var tokens = [SearchToken]()
@Published var suggestedTokens = [SearchToken]()
@Published var dataSet: DataSet = .national
@Published var filterOption = PlayerFilterOption.all
@Published var hideAssimilation: Bool = false
@Published var ascending: Bool = true
@Published var sortOption: SortOption = .rank
@Published var selectedPlayers: Set<ImportedPlayer> = Set()
@Published var filterSelectionEnabled: Bool = false
@Published var isPresented: Bool = false
@Published var selectedAgeCategory: FederalTournamentAge = .unlisted
@Published var mostRecentDate: Date? = nil
var selectionIsOver: Bool {
if allowSingleSelection && selectedPlayers.count == 1 {
return true
} else if allowMultipleSelection && selectedPlayers.count == allowSelection {
return true
}
return false
}
var allowMultipleSelection: Bool {
allowSelection > 1 || allowSelection == -1
}
var allowSingleSelection: Bool {
allowSelection == 1
}
var debounceTrigger: Double {
(dataSet == .national || dataSet == .ligue) ? 0.4 : 0.1
}
var throttleTrigger: Double {
(dataSet == .national || dataSet == .ligue) ? 0.15 : 0.1
}
var contentUnavailableMessage: String {
var message = ["Vérifiez l'ortographe ou lancez une nouvelle recherche."]
if tokens.isEmpty {
message.append("Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois, dans ce cas, Padel Club ne pourra pas le trouver.")
}
return message.joined(separator: "\n")
}
func codeClubs() -> [String] {
let clubs: [Club] = DataStore.shared.user.clubsObjects()
return clubs.compactMap { $0.code }
}
func getCodeClub() -> String? {
if let codeClub { return codeClub }
if let userCodeClub = DataStore.shared.user.currentPlayerData()?.clubCode { return userCodeClub }
return nil
}
func getLigueName() -> String? {
if let ligueName { return ligueName }
if let userLigueName = DataStore.shared.user.currentPlayerData()?.ligueName { return userLigueName }
return nil
}
func showIndex() -> Bool {
if (dataSet == .national || dataSet == .ligue) { return isFiltering() }
if filterOption == .all { return isFiltering() }
return true
}
func isFiltering() -> Bool {
searchText.isEmpty == false || tokens.isEmpty == false || hideAssimilation || selectedAgeCategory != .unlisted
}
func prompt(forDataSet: DataSet) -> String {
switch forDataSet {
case .national:
if let mostRecentDate {
return "base fédérale \(mostRecentDate.monthYearFormatted)"
} else {
return "rechercher"
}
case .ligue:
return "dans cette ligue"
case .club:
return "dans ce club"
case .favoriteClubs, .favoritePlayers:
return "dans mes favoris"
}
}
func label(forDataSet: DataSet) -> String {
switch forDataSet {
case .national:
return "National"
case .ligue:
return (ligueName)?.capitalized ?? "Ma ligue"
case .club:
return (clubName)?.capitalized ?? "Mon club"
case .favoriteClubs:
return "Clubs favoris"
case .favoritePlayers:
return "Joueurs favoris"
}
}
func words() -> [String] {
return searchText.canonicalVersionWithPunctuation.trimmed.components(separatedBy: .whitespaces)
}
func wordsPredicates() -> NSPredicate? {
let words = words().filter({ $0.isEmpty == false })
switch words.count {
case 2:
let predicates = [
NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[0], words[1]),
NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[1], words[0]),
]
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
default:
return nil
}
}
func orPredicate() -> NSPredicate? {
var predicates : [NSPredicate] = []
let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces)
let canonicalVersionWithoutPunctuation = searchText.canonicalVersion.components(separatedBy: allowedCharacterSet.inverted).joined().trimmed
let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation.trimmed
switch tokens.first {
case .none:
if canonicalVersionWithoutPunctuation.isEmpty == false {
let wordsPredicates = wordsPredicates()
if let wordsPredicates {
predicates.append(wordsPredicates)
} else {
predicates.append(NSPredicate(format: "license contains[cd] %@", canonicalVersionWithoutPunctuation))
}
predicates.append(NSPredicate(format: "canonicalFullName contains[cd] %@", canonicalVersionWithoutPunctuation))
let components = canonicalVersionWithoutPunctuation.split(separator: " ")
let pattern = components.joined(separator: ".*")
let predicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern)
predicates.append(predicate)
}
case .ligue:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "ligueName == nil"))
} else {
predicates.append(NSPredicate(format: "ligueName contains[cd] %@", canonicalVersionWithoutPunctuation))
}
case .club:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "clubName == nil"))
} else {
predicates.append(NSPredicate(format: "clubName contains[cd] %@", canonicalVersionWithoutPunctuation))
}
case .rankMoreThan:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank >= %@", canonicalVersionWithoutPunctuation))
}
case .rankLessThan:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank <= %@", canonicalVersionWithoutPunctuation))
}
case .rankBetween:
let values = canonicalVersionWithPunctuation.components(separatedBy: ",")
if canonicalVersionWithPunctuation.isEmpty || values.count != 2 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank BETWEEN {%@,%@}", values.first!, values.last!))
}
case .age:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
predicates.append(NSPredicate(format: "birthYear == 0"))
} else if let birthYear = Int(canonicalVersionWithoutPunctuation) {
predicates.append(NSPredicate(format: "birthYear == %@", birthYear.formattedAsRawString()))
}
}
if predicates.isEmpty {
return nil
}
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
}
func predicate() -> NSPredicate? {
var predicates : [NSPredicate?] = [
orPredicate(),
filterOption == .male ?
NSPredicate(format: "male == YES") :
nil,
filterOption == .female ?
NSPredicate(format: "male == NO") :
nil,
]
if let mostRecentDate {
predicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg))
}
if hideAssimilation {
predicates.append(NSPredicate(format: "assimilation == %@", "Non"))
}
if selectedAgeCategory != .unlisted {
let computedBirthYear = selectedAgeCategory.computedBirthYear()
if let left = computedBirthYear.0 {
predicates.append(NSPredicate(format: "birthYear >= %@", left.formattedAsRawString()))
}
if let right = computedBirthYear.1 {
predicates.append(NSPredicate(format: "birthYear <= %@", right.formattedAsRawString()))
}
}
switch dataSet {
case .national:
break
case .ligue:
if let ligueName = getLigueName() {
predicates.append(NSPredicate(format: "ligueName == %@", ligueName))
} else {
predicates.append(NSPredicate(format: "ligueName == nil"))
}
case .club:
if let codeClub = getCodeClub() {
predicates.append(NSPredicate(format: "clubCode == %@", codeClub))
} else {
predicates.append(NSPredicate(format: "clubCode == nil"))
}
case .favoriteClubs:
predicates.append(NSPredicate(format: "clubCode IN %@", codeClubs()))
case .favoritePlayers:
//todo
predicates.append(NSPredicate(format: "license == nil"))
}
if hidePlayers?.isEmpty == false {
predicates.append(NSPredicate(format: "NOT (license IN %@)", hidePlayers!))
}
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates.compactMap({ $0 }))
}
func sortDescriptors() -> [SortDescriptor<ImportedPlayer>] {
sortOption.sortDescriptors(ascending, dataSet: dataSet)
}
func nsSortDescriptors() -> [NSSortDescriptor] {
sortDescriptors().map { NSSortDescriptor($0) }
}
static func getSpecialSlashPredicate(inputString: String) -> NSPredicate? {
// Define a regular expression to find slashes between alphabetic characters (not digits)
print(inputString)
let pattern = /(\b[A-Za-z]+)\s*\/\s*([A-Za-z]+\b)/
// Find matches in the input string
guard let match = inputString.firstMatch(of: pattern) else {
print("No valid name pairs found")
return nil
}
let lastName1 = match.output.1.trimmingCharacters(in: .whitespacesAndNewlines)
let lastName2 = match.output.2.trimmingCharacters(in: .whitespacesAndNewlines)
// Ensure both names are not empty
guard !lastName1.isEmpty && !lastName2.isEmpty else {
print("One or both names are empty")
return nil
}
// Create the NSPredicate for searching in the `lastName` field
let predicate = NSPredicate(format: "lastName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@", lastName1, lastName2)
// Output the result
//print("Generated Predicate: \(predicate)")
return predicate
}
static func pastePredicate(pasteField: String, mostRecentDate: Date?, filterOption: PlayerFilterOption) -> NSPredicate? {
let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces)
// Remove all characters that are not in the allowedCharacterSet
var text = pasteField.canonicalVersion.components(separatedBy: allowedCharacterSet.inverted).joined().trimmedMultiline
// Define the regex pattern to match digits
let digitPattern = /\d+/
// Replace all occurrences of the pattern (digits) with an empty string
text = text.replacing(digitPattern, with: "")
let textStrings: [String] = text.components(separatedBy: .whitespacesAndNewlines)
let nonEmptyStrings: [String] = textStrings.compactMap { $0.isEmpty ? nil : $0 }
let nameComponents = nonEmptyStrings.filter({ $0 != "de" && $0 != "la" && $0 != "le" && $0.count > 1 })
var andPredicates = [NSPredicate]()
var orPredicates = [NSPredicate]()
//self.wordsCount = nameComponents.count
if let slashPredicate = getSpecialSlashPredicate(inputString: pasteField) {
orPredicates.append(slashPredicate)
}
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.append(contentsOf: nameComponents.pairs().map {
return NSPredicate(format: "(firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@) OR (firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@)", $0, $1, $1, $0) })
} else {
orPredicates.append(contentsOf: nameComponents.map { NSPredicate(format: "firstName contains[cd] %@ OR lastName contains[cd] %@", $0,$0) })
}
let components = text.split(separator: " ")
let pattern = components.joined(separator: ".*")
print(text, pattern)
let canonicalFullNamePredicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern)
orPredicates.append(canonicalFullNamePredicate)
let matches = pasteField.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
}
}
enum SearchToken: String, CaseIterable, Identifiable {
case club = "club"
case ligue = "ligue"
case rankMoreThan = "rang >"
case rankLessThan = "rang <"
case rankBetween = "rang <>"
case age = "âge sportif"
var id: String {
rawValue
}
var message: String {
switch self {
case .club:
return "Taper le nom d'un club pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
case .ligue:
return "Taper le nom d'une ligue pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
case .rankMoreThan:
return "Taper un nombre pour chercher les joueurs ayant un classement supérieur ou égale."
case .rankLessThan:
return "Taper un nombre pour chercher les joueurs ayant un classement inférieur ou égale."
case .rankBetween:
return "Taper deux nombres séparés par une virgule pour chercher les joueurs dans cette intervalle de classement"
case .age:
return "Taper une année de naissance"
}
}
var titleLabel: String {
switch self {
case .club:
return "Chercher un club"
case .ligue:
return "Chercher une ligue"
case .rankMoreThan, .rankLessThan:
return "Chercher un rang"
case .rankBetween:
return "Chercher une intervalle de classement"
case .age:
return "Chercher une année de naissance"
}
}
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
case .club:
return "Club"
case .ligue:
return "Ligue"
case .rankMoreThan:
return "Rang supérieur ou égale à"
case .rankLessThan:
return "Rang inférieur ou égale à"
case .rankBetween:
return "Rang entre deux valeurs"
case .age:
return "Année de naissance"
}
}
var shortLocalizedLabel: String {
switch self {
case .club:
return "Club"
case .ligue:
return "Ligue"
case .rankMoreThan:
return "Rang ≥"
case .rankLessThan:
return "Rang ≤"
case .rankBetween:
return "Rang ≥,≤"
case .age:
return "Né(e) en"
}
}
func icon() -> String {
switch self {
case .club:
return "house.and.flag.fill"
case .ligue:
return "house.and.flag.fill"
case .rankMoreThan:
return "figure.racquetball"
case .rankLessThan:
return "figure.racquetball"
case .rankBetween:
return "figure.racquetball"
case .age:
return "figure.racquetball"
}
}
var systemImage: String {
switch self {
case .club:
return "house.and.flag.fill"
case .ligue:
return "house.and.flag.fill"
case .rankMoreThan:
return "figure.racquetball"
case .rankLessThan:
return "figure.racquetball"
case .rankBetween:
return "figure.racquetball"
case .age:
return "figure.racquetball"
}
}
}
enum DataSet: Int, Identifiable {
case national
case ligue
case club
case favoriteClubs
case favoritePlayers
static let allCases : [DataSet] = [.national, .ligue, .club, .favoriteClubs]
var id: Int { rawValue }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
case .national:
return "National"
case .ligue:
return "Ligue"
case .club:
return "Club"
case .favoritePlayers, .favoriteClubs:
return "Favori"
}
}
var tokens: [SearchToken] {
var _tokens : [SearchToken] = []
switch self {
case .national:
_tokens = [.club, .ligue, .rankMoreThan, .rankLessThan, .rankBetween]
case .ligue:
_tokens = [.club, .rankMoreThan, .rankLessThan, .rankBetween]
case .club:
_tokens = [.rankMoreThan, .rankLessThan, .rankBetween]
case .favoritePlayers, .favoriteClubs:
_tokens = [.rankMoreThan, .rankLessThan, .rankBetween]
}
_tokens.append(.age)
return _tokens
}
}
enum SortOption: Int, CaseIterable, Identifiable {
case name
case rank
case tournamentCount
case points
case progression
var id: Int { self.rawValue }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
case .name:
return "Nom"
case .rank:
return "Rang"
case .tournamentCount:
return "Tournoi"
case .points:
return "Points"
case .progression:
return "Progression"
}
}
func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor<ImportedPlayer>] {
switch self {
case .name:
return [SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation)]
case .rank:
if (dataSet == .national || dataSet == .ligue) {
return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)]
} else {
return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
}
case .tournamentCount:
return [SortDescriptor(\ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
case .points:
return [SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
case .progression:
return [SortDescriptor(\ImportedPlayer.progression, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
}
}
}
enum PlayerFilterOption: Int, Hashable, CaseIterable, Identifiable {
case all = -1
case male = 1
case female = 0
var id: Int { rawValue }
func icon() -> String {
switch self {
case .all:
return "Tous"
case .male:
return "Homme"
case .female:
return "Femme"
}
}
var localizedPlayerLabel: String {
switch self {
case .female:
return "joueuse"
default:
return "joueur"
}
}
}