// // 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 = Set() @Published var filterSelectionEnabled: Bool = false @Published var isPresented: Bool = false 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 pas, 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 false } if filterOption == .all { return false } return true } 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 canonicalVersionWithoutPunctuation = searchText.canonicalVersion let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation 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: " ").sorted() 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!)) } } 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")) } 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] { sortOption.sortDescriptors(ascending, dataSet: dataSet) } func nsSortDescriptors() -> [NSSortDescriptor] { sortDescriptors().map { NSSortDescriptor($0) } } } enum SearchToken: String, CaseIterable, Identifiable { case club = "club" case ligue = "ligue" case rankMoreThan = "rang >" case rankLessThan = "rang <" case rankBetween = "rang <>" 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" } } 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" } } 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" } } 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 ≥,≤" } } 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" } } 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" } } } 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] { switch self { case .national: return [.club, .ligue, .rankMoreThan, .rankLessThan, .rankBetween] case .ligue: return [.club, .rankMoreThan, .rankLessThan, .rankBetween] case .club: return [.rankMoreThan, .rankLessThan, .rankBetween] case .favoritePlayers, .favoriteClubs: return [.rankMoreThan, .rankLessThan, .rankBetween] } } } enum SortOption: Int, CaseIterable, Identifiable { case name case rank case tournamentCount case points 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" } } func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor] { 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)] } } }