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.
483 lines
23 KiB
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|