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/Views/Shared/SelectablePlayerListView.swift

483 lines
23 KiB

//
// SelectablePlayerListView.swift
// Padel Tournament
//
// Created by Razmig Sarkissian on 10/02/2024.
//
import SwiftUI
import CoreData
import Combine
import LeStorage
typealias PlayerSelectionAction = ((Set<ImportedPlayer>) -> ())
typealias ContentUnavailableAction = ((SearchViewModel) -> ())
struct SelectablePlayerListView: View {
let allowSelection: Int
let playerSelectionAction: PlayerSelectionAction?
let contentUnavailableAction: ContentUnavailableAction?
@EnvironmentObject var dataStore: DataStore
@Environment(ImportObserver.self) private var importObserver: ImportObserver
@StateObject private var searchViewModel: SearchViewModel
@Environment(\.dismiss) var dismiss
var lastDataSource: String? {
dataStore.appSettings.lastDataSource
}
@State private var searchText: String = ""
var mostRecentDate: Date? {
guard let lastDataSource else { return nil }
return URL.importDateFormatter.date(from: lastDataSource)
}
init(allowSelection: Int = 0, searchField: String? = nil, dataSet: DataSet = .national, filterOption: PlayerFilterOption = .all, hideAssimilation: Bool = false, ascending: Bool = true, sortOption: SortOption = .rank, fromPlayer: FederalPlayer? = nil, codeClub: String? = nil, ligue: String? = nil, showFemaleInMaleAssimilation: Bool = false, tokens: [SearchToken] = [], hidePlayers: [String]? = nil, playerSelectionAction: PlayerSelectionAction? = nil, contentUnavailableAction: ContentUnavailableAction? = nil) {
self.allowSelection = allowSelection
self.playerSelectionAction = playerSelectionAction
self.contentUnavailableAction = contentUnavailableAction
self.searchText = searchField ?? ""
let searchViewModel = SearchViewModel()
searchViewModel.tokens = tokens
searchViewModel.searchText = searchField ?? ""
searchViewModel.debouncableText = searchField ?? ""
searchViewModel.showFemaleInMaleAssimilation = showFemaleInMaleAssimilation
searchViewModel.searchText = searchField ?? ""
searchViewModel.isPresented = allowSelection != 0
searchViewModel.allowSelection = allowSelection
searchViewModel.codeClub = fromPlayer?.clubCode ?? codeClub
searchViewModel.clubName = nil
searchViewModel.ligueName = fromPlayer?.ligue ?? ligue
searchViewModel.dataSet = dataSet
searchViewModel.filterOption = fromPlayer == nil ? filterOption : fromPlayer!.isManPlayer ? .male : .female
searchViewModel.hideAssimilation = hideAssimilation
searchViewModel.ascending = ascending
searchViewModel.sortOption = sortOption
searchViewModel.hidePlayers = hidePlayers
_searchViewModel = StateObject(wrappedValue: searchViewModel)
}
var body: some View {
VStack(spacing: 0) {
if importObserver.isImportingFile() == false {
if searchViewModel.filterSelectionEnabled == false {
VStack {
HStack {
Picker(selection: $searchViewModel.filterOption) {
ForEach(PlayerFilterOption.allCases, id: \.self) { scope in
Text(scope.icon().capitalized)
}
} label: {
}
.pickerStyle(.segmented)
Menu {
Section {
ForEach(SourceFileManager.getSortOption()) { option in
Toggle(isOn: .init(get: {
return searchViewModel.sortOption == option
}, set: { value in
if searchViewModel.sortOption == option {
searchViewModel.ascending.toggle()
}
searchViewModel.sortOption = option
})) {
Label(option.localizedLabel(), systemImage: searchViewModel.sortOption == option ? (searchViewModel.ascending ? "chevron.up" : "chevron.down") : "")
}
}
} header: {
Text("Trier par")
}
Divider()
if SourceFileManager.canFilterByAge() {
Section {
Picker(selection: $searchViewModel.selectedAgeCategory) {
ForEach(FederalTournamentAge.allCases) { ageCategory in
Text(ageCategory.localizedLabel(.title)).tag(ageCategory)
}
} label: {
Text("Catégorie d'âge")
}
} header: {
Text("Catégorie d'âge")
}
Divider()
}
Section {
Toggle(isOn: .init(get: {
return searchViewModel.hideAssimilation == false
}, set: { value in
searchViewModel.hideAssimilation.toggle()
})) {
Text("Afficher")
}
Toggle(isOn: .init(get: {
return searchViewModel.hideAssimilation == true
}, set: { value in
searchViewModel.hideAssimilation.toggle()
})) {
Text("Masquer")
}
} header: {
Text("Assimilés")
}
} label: {
VStack(alignment: .trailing) {
Label(searchViewModel.sortOption.localizedLabel(), systemImage: searchViewModel.ascending ? "chevron.up" : "chevron.down")
if searchViewModel.selectedAgeCategory != .unlisted {
Text(searchViewModel.selectedAgeCategory.localizedLabel()).font(.caption)
}
}
}
}
}
.padding(.bottom)
.padding(.horizontal)
.background(Material.thick)
Divider()
}
MySearchView(searchViewModel: searchViewModel, contentUnavailableAction: contentUnavailableAction)
.environment(\.editMode, searchViewModel.allowMultipleSelection ? .constant(.active) : .constant(.inactive))
.searchable(text: $searchViewModel.debouncableText, tokens: $searchViewModel.tokens, suggestedTokens: $searchViewModel.suggestedTokens, isPresented: $searchViewModel.isPresented, placement: .navigationBarDrawer(displayMode: .always), prompt: searchViewModel.prompt(forDataSet: searchViewModel.dataSet), token: { token in
Text(token.shortLocalizedLabel)
})
.keyboardType(.alphabet)
.autocorrectionDisabled()
// .searchSuggestions({
// ForEach(searchViewModel.suggestedTokens) { token in
// Button {
// searchViewModel.tokens.append(token)
// } label: {
// Label(token.localizedLabel(), systemImage: token.icon())
// }
// }
// })
.onReceive(
searchViewModel.$debouncableText
.debounce(for: .seconds(searchViewModel.debounceTrigger), scheduler: DispatchQueue.main)
.throttle(for: .seconds(searchViewModel.throttleTrigger), scheduler: DispatchQueue.main, latest: true)
) {
guard !$0.isEmpty else {
if searchViewModel.searchText.isEmpty == false {
searchViewModel.searchText = ""
}
return
}
print(">> searching for: \($0)")
searchViewModel.searchText = $0
}
.scrollDismissesKeyboard(.immediately)
.navigationBarBackButtonHidden(searchViewModel.allowMultipleSelection)
.toolbarBackground(.visible, for: .bottomBar)
// .toolbarRole(searchViewModel.allowMultipleSelection ? .navigationStack : .editor)
.interactiveDismissDisabled(searchViewModel.selectedPlayers.isEmpty == false)
.navigationTitle(searchViewModel.label(forDataSet: searchViewModel.dataSet))
.navigationBarTitleDisplayMode(.inline)
} else {
List {
}
}
}
.id(importObserver.currentImportDate)
.overlay {
if let importedFile = SourceFileManager.shared.mostRecentDateAvailable, importObserver.isImportingFile() {
ContentUnavailableView("Importation en cours", systemImage: "square.and.arrow.down", description: Text("Padel Club récupère les données de \(importedFile.monthYearFormatted)"))
}
}
.onAppear {
searchViewModel.mostRecentDate = mostRecentDate
if searchViewModel.tokens.isEmpty && searchText.isEmpty {
searchViewModel.debouncableText.removeAll()
searchViewModel.searchText.removeAll()
}
searchViewModel.allowSelection = allowSelection
searchViewModel.selectedPlayers.removeAll()
searchViewModel.hideAssimilation = false
searchViewModel.ascending = true
searchViewModel.sortOption = .rank
searchViewModel.suggestedTokens = searchViewModel.dataSet.tokens
// searchViewModel.fromPlayer = nil
// searchViewModel.codeClub = nil
// searchViewModel.ligueName = nil
// searchViewModel.user = nil
// searchViewModel.dataSet = .national
// searchViewModel.filterOption = .all
}
.onChange(of: searchViewModel.selectedPlayers) {
if let playerSelectionAction, searchViewModel.selectionIsOver {
playerSelectionAction(searchViewModel.selectedPlayers)
dismiss()
}
if searchViewModel.tokens.isEmpty && searchViewModel.searchText.isEmpty == false {
searchViewModel.debouncableText = ""
}
if searchViewModel.selectedPlayers.isEmpty && searchViewModel.filterSelectionEnabled {
searchViewModel.filterSelectionEnabled = false
} else {
searchViewModel.filterSelectionEnabled = true
}
}
.onChange(of: searchViewModel.dataSet) {
searchViewModel.suggestedTokens = searchViewModel.dataSet.tokens
if searchViewModel.filterSelectionEnabled {
searchViewModel.filterSelectionEnabled = false
}
}
.onChange(of: searchViewModel.tokens) {
if searchViewModel.tokens.first == .age {
searchViewModel.selectedAgeCategory = .unlisted
}
}
.toolbar {
if searchViewModel.allowMultipleSelection {
ToolbarItemGroup(placement: .topBarLeading) {
Button(role: .cancel) {
searchViewModel.selectedPlayers.removeAll()
dismiss()
} label: {
Text("Annuler")
}
}
if searchViewModel.selectedPlayers.isEmpty == false {
ToolbarItem(placement: .topBarTrailing) {
ButtonValidateView {
if let playerSelectionAction {
playerSelectionAction(searchViewModel.selectedPlayers)
}
dismiss()
}
}
ToolbarItem(placement: .status) {
let count = searchViewModel.selectedPlayers.count
VStack(spacing: 0) {
Text(count.formatted() + " joueur" + count.pluralSuffix + " séléctionné" + count.pluralSuffix).font(.footnote).foregroundStyle(.secondary)
FooterButtonView("\(searchViewModel.filterSelectionEnabled ? "masquer" : "voir") la liste") {
searchViewModel.filterSelectionEnabled.toggle()
}
}
}
}
}
}
// .modifierWithCondition(searchViewModel.user != nil) { thisView in
// thisView
.toolbarTitleMenu {
Picker(selection: $searchViewModel.dataSet) {
ForEach(DataSet.allCases) { dataSet in
Text(searchViewModel.label(forDataSet: dataSet)).tag(dataSet)
}
} label: {
}
}
// }
// .bottomBarAlternative(hide: searchViewModel.selectedPlayers.isEmpty) {
// ZStack {
// HStack{
// Button {
// searchViewModel.filterSelectionEnabled.toggle()
// } label: {
// if searchViewModel.filterSelectionEnabled {
// Image(systemName: "line.3.horizontal.decrease.circle.fill")
// } else {
// Image(systemName: "line.3.horizontal.decrease.circle")
// }
// }
// Spacer()
// }
// Button {
// if let playerSelectionAction {
// playerSelectionAction(searchViewModel.selectedPlayers)
// }
// dismiss()
// } label: {
// Text("Ajouter le" + searchViewModel.selectedPlayers.count.pluralSuffix + " \(searchViewModel.selectedPlayers.count) joueur" + searchViewModel.selectedPlayers.count.pluralSuffix)
// }
// .buttonStyle(.borderedProminent)
// }
// }
}
}
struct MySearchView: View {
@Environment(\.isSearching) private var isSearching
@Environment(\.dismissSearch) private var dismissSearch
@Environment(\.editMode) var editMode
@ObservedObject var searchViewModel: SearchViewModel
@FetchRequest private var players: FetchedResults<ImportedPlayer>
let contentUnavailableAction: ContentUnavailableAction?
init(searchViewModel: SearchViewModel, contentUnavailableAction: ContentUnavailableAction? = nil) {
self.contentUnavailableAction = contentUnavailableAction
_searchViewModel = ObservedObject(wrappedValue: searchViewModel)
_players = FetchRequest<ImportedPlayer>(sortDescriptors: searchViewModel.sortDescriptors(), predicate: searchViewModel.predicate())
}
var body: some View {
playersView
.overlay {
overlayView()
}
.onChange(of: isSearching) {
if isSearching && searchViewModel.filterSelectionEnabled {
searchViewModel.filterSelectionEnabled = false
}
}
.onChange(of: searchViewModel.filterSelectionEnabled) {
if searchViewModel.filterSelectionEnabled && isSearching {
dismissSearch()
}
}
.listStyle(.grouped)
.headerProminence(.increased)
.scrollDismissesKeyboard(.immediately)
}
var specificBugFixUUID: String {
if searchViewModel.dataSet == .national || searchViewModel.dataSet == .ligue {
return UUID().uuidString
} else {
if searchViewModel.tokens.isEmpty && isSearching {
return UUID().uuidString
}
return "specificBugFixUUID"
}
}
@ViewBuilder
var playersView: some View {
if searchViewModel.allowMultipleSelection {
List(selection: $searchViewModel.selectedPlayers) {
if searchViewModel.filterSelectionEnabled {
let array = Array(searchViewModel.selectedPlayers)
Section {
ForEach(array) { player in
ImportedPlayerView(player: player, showFemaleInMaleAssimilation: searchViewModel.showFemaleInMaleAssimilation)
}
.onDelete { indexSet in
for index in indexSet {
let p = array[index]
searchViewModel.selectedPlayers.remove(p)
}
}
} header: {
Text(searchViewModel.selectedPlayers.count.formatted() + " " + searchViewModel.filterOption.localizedPlayerLabel + searchViewModel.selectedPlayers.count.pluralSuffix)
}
} else if (searchViewModel.isPresented == true && searchViewModel.dataSet == .national && searchViewModel.dataSet == .ligue && searchViewModel.searchText.isEmpty == true) {
} else {
Section {
ForEach(players, id: \.self) { player in
ImportedPlayerView(player: player, index: nil, showFemaleInMaleAssimilation: searchViewModel.showFemaleInMaleAssimilation)
}
} header: {
if players.isEmpty == false {
headerView()
}
}
}
}
.id(specificBugFixUUID)
} else {
List {
if searchViewModel.dataSet == .national || searchViewModel.dataSet == .ligue {
if searchViewModel.allowSingleSelection {
Section {
ForEach(players) { player in
Button {
searchViewModel.selectedPlayers.insert(player)
} label: {
ImportedPlayerView(player: player, showFemaleInMaleAssimilation: searchViewModel.showFemaleInMaleAssimilation)
}
.buttonStyle(.plain)
}
} header: {
if players.isEmpty == false {
headerView()
}
}
.id(UUID())
} else {
Section {
ForEach(players.indices, id: \.self) { index in
let player = players[index]
ImportedPlayerView(player: player, index: searchViewModel.showIndex() ? (index + 1) : nil, showFemaleInMaleAssimilation: searchViewModel.showFemaleInMaleAssimilation)
}
} header: {
if players.isEmpty == false {
headerView()
}
}
.id(UUID())
}
} else {
Section {
ForEach(players.indices, id: \.self) { index in
let player = players[index]
if searchViewModel.allowSingleSelection {
Button {
searchViewModel.selectedPlayers.insert(player)
} label: {
ImportedPlayerView(player: player, index: searchViewModel.showIndex() ? (index + 1) : nil, showFemaleInMaleAssimilation: searchViewModel.showFemaleInMaleAssimilation)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity)
.buttonStyle(.plain)
} else {
ImportedPlayerView(player: player, index: searchViewModel.showIndex() ? (index + 1) : nil, showFemaleInMaleAssimilation: searchViewModel.showFemaleInMaleAssimilation)
}
}
} header: {
if players.isEmpty == false {
headerView()
}
}
.id(UUID())
}
}
}
}
private func headerView() -> some View {
HStack {
Text("\(players.count.formatted()) \( searchViewModel.filterOption.localizedPlayerLabel)\( players.count.pluralSuffix)")
Spacer()
}
}
@ViewBuilder
func overlayView() -> some View {
if let token = searchViewModel.tokens.first, searchViewModel.searchText.isEmpty {
ContentUnavailableView(token.titleLabel, systemImage: token.systemImage, description: Text(token.message))
} else if players.isEmpty && searchViewModel.filterSelectionEnabled == false && searchViewModel.searchText.isEmpty == false {
ContentUnavailableView {
Label("Aucun résultat pour «\(searchViewModel.searchText)»", systemImage: "magnifyingglass")
} description: {
Text(searchViewModel.contentUnavailableMessage)
} actions: {
RowButtonView("Lancer une nouvelle recherche") {
searchViewModel.debouncableText = ""
}
.padding()
if let contentUnavailableAction {
RowButtonView("Créer \(searchViewModel.searchText)") {
contentUnavailableAction(searchViewModel)
}
.padding()
}
}
}
}
}