// // InscriptionManagerView.swift // PadelClub // // Created by Razmig Sarkissian on 29/02/2024. // import SwiftUI import TipKit struct InscriptionManagerView: View { @EnvironmentObject var dataStore: DataStore @FetchRequest( sortDescriptors: [NSSortDescriptor(keyPath: \ImportedPlayer.rank, ascending: true)], animation: .default) private var fetchPlayers: FetchedResults var tournament: Tournament @State private var searchField: String = "" @State private var presentSearch: Bool = false @State private var presentPlayerSearch: Bool = false @State private var presentPlayerCreation: Bool = false @State private var presentImportView: Bool = false @State private var isLearningMore: Bool = false @State private var createdPlayers: Set = Set() @State private var createdPlayerIds: Set = Set() @State private var editedTeam: TeamRegistration? @State private var pasteString: String? @State private var currentRankSourceDate: Date? @State private var confirmUpdateRank = false @State private var selectionSearchField: String? @State private var autoSelect: Bool = false let slideToDeleteTip = SlideToDeleteTip() let inscriptionManagerWomanRankTip = InscriptionManagerWomanRankTip() let fileTip = InscriptionManagerFileInputTip() let pasteTip = InscriptionManagerPasteInputTip() let searchTip = InscriptionManagerSearchInputTip() let createTip = InscriptionManagerCreateInputTip() let rankUpdateTip = InscriptionManagerRankUpdateTip() let padelBeachExportTip = PadelBeachExportTip() let padelBeachImportTip = PadelBeachImportTip() let categoryOption: PlayerFilterOption let filterable: Bool let dates = Set(SourceFileManager.shared.allFilesSortedByDate(true).map({ $0.dateFromPath })).sorted().reversed() init(tournament: Tournament) { self.tournament = tournament _currentRankSourceDate = State(wrappedValue: tournament.rankSourceDate) switch tournament.tournamentCategory { case .women: categoryOption = .female filterable = false default: categoryOption = .all filterable = true } } var body: some View { VStack(spacing: 0) { _managementView() if _isEditingTeam() { _buildingTeamView() } else if tournament.unsortedTeams().isEmpty { _inscriptionTipsView() } else { _teamRegisteredView() } } .sheet(isPresented: $isLearningMore) { LearnMoreSheetView(tournament: tournament) .tint(.master) } .sheet(isPresented: $presentPlayerSearch, onDismiss: { selectionSearchField = nil }) { NavigationStack { SelectablePlayerListView(allowSelection: -1, filterOption: _filterOption(), showFemaleInMaleAssimilation: tournament.tournamentCategory.showFemaleInMaleAssimilation) { players in selectionSearchField = nil players.forEach { player in let newPlayer = PlayerRegistration(importedPlayer: player) newPlayer.setWeight(in: tournament) createdPlayers.insert(newPlayer) createdPlayerIds.insert(newPlayer.id) } } contentUnavailableAction: { searchViewModel in selectionSearchField = searchViewModel.searchText presentPlayerSearch = false presentPlayerCreation = true } } .tint(.master) } .sheet(isPresented: $presentPlayerCreation) { PlayerPopoverView(source: _searchSource(), sex: _addPlayerSex()) { p in createdPlayers.insert(p) createdPlayerIds.insert(p.id) } .tint(.master) } .sheet(isPresented: $presentImportView) { NavigationStack { FileImportView(fileContent: nil) } .tint(.master) } .onChange(of: tournament.prioritizeClubMembers) { _save() } .onChange(of: tournament.teamSorting) { _save() } .onChange(of: currentRankSourceDate) { if let currentRankSourceDate, tournament.rankSourceDate != currentRankSourceDate { confirmUpdateRank = true } } .sheet(isPresented: $confirmUpdateRank, onDismiss: { currentRankSourceDate = tournament.rankSourceDate }) { UpdateSourceRankDateView(currentRankSourceDate: $currentRankSourceDate, confirmUpdateRank: $confirmUpdateRank, tournament: tournament) .tint(.master) } .toolbar { if _isEditingTeam() { ToolbarItem(placement: .cancellationAction) { Button("Annuler", role: .cancel) { pasteString = nil editedTeam = nil createdPlayers.removeAll() createdPlayerIds.removeAll() } } } else { ToolbarItem(placement: .navigationBarTrailing) { Menu { if tournament.inscriptionClosed() == false { Menu { _sortingTypePickerView() } label: { Text("Méthode de sélection") Text(tournament.teamSorting.localizedLabel()) } Divider() rankingDateSourcePickerView(showDateInLabel: true) if tournament.teamSorting == .inscriptionDate { Divider() _prioritizeClubMembersButton() Button("Bloquer une place") { _createTeam() } } Divider() Button { tournament.lockRegistration() _save() } label: { Label("Clôturer", systemImage: "lock") } Divider() ShareLink(item: tournament.pasteDataForImporting()) { Label("Exporter les paires", systemImage: "square.and.arrow.up") } Button { presentImportView = true } label: { Label("Importer beach-padel", systemImage: "square.and.arrow.down") } Link(destination: SourceFileManager.beachPadel) { Label("beach-padel.app.fft.fr", systemImage: "safari") } } else { Button { tournament.closedRegistrationDate = nil _save() } label: { Label("Ré-ouvrir", systemImage: "lock.open") } } } label: { if tournament.inscriptionClosed() == false { LabelOptions() } else { Label("Clôturer", systemImage: "lock") } } } } } .navigationBarBackButtonHidden(_isEditingTeam()) .toolbarBackground(.visible, for: .navigationBar) .navigationTitle("Inscriptions") .navigationBarTitleDisplayMode(.inline) } private func _isEditingTeam() -> Bool { createdPlayerIds.isEmpty == false || editedTeam != nil || pasteString != nil } private func _teamRegisteredView() -> some View { List { let unfilteredTeams = tournament.sortedTeams() if presentSearch == false { _rankHandlerView() _relatedTips() _informationView(count: unfilteredTeams.count) } let teams = searchField.isEmpty ? unfilteredTeams : unfilteredTeams.filter({ $0.contains(searchField.canonicalVersion) }) if teams.isEmpty && searchField.isEmpty == false { ContentUnavailableView { Label("Aucun résultat", systemImage: "person.2.slash") } description: { Text("\(searchField) est introuvable dans les équipes inscrites.") } actions: { RowButtonView("Modifier la recherche") { searchField = "" presentSearch = true } RowButtonView("Créer une équipe") { Task { await MainActor.run() { fetchPlayers.nsPredicate = _pastePredicate(pasteField: searchField, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable) pasteString = searchField } } } RowButtonView("D'accord") { searchField = "" presentSearch = false } } } ForEach(teams) { team in let teamIndex = team.index(in: unfilteredTeams) Section { TeamDetailView(team: team) } header: { TeamHeaderView(team: team, teamIndex: teamIndex, tournament: tournament) } footer: { _teamFooterView(team) } .headerProminence(.increased) } } .searchable(text: $searchField, isPresented: $presentSearch, prompt: Text("Chercher parmi les équipes inscrites")) .keyboardType(.alphabet) .autocorrectionDisabled() } @MainActor private func _managementView() -> some View { HStack { Button { presentPlayerCreation = true } label: { HStack(spacing: 4) { Image(systemName: "person.fill.badge.plus") .resizable() .scaledToFit() .frame(width: 20) Text("Créer") .font(.headline) } .frame(maxWidth: .infinity) } PasteButton(payloadType: String.self) { strings in guard let first = strings.first else { return } Task { await MainActor.run { fetchPlayers.nsPredicate = _pastePredicate(pasteField: first, mostRecentDate: SourceFileManager.shared.mostRecentDateAvailable) pasteString = first autoSelect = true } } } Button { presentPlayerSearch = true } label: { HStack(spacing: 4) { Image(systemName: "person.fill.viewfinder") .resizable() .scaledToFit() .frame(width: 20) Text("FFT") .font(.headline) } .frame(maxWidth: .infinity) } } .buttonStyle(.borderedProminent) .tint(.logoBackground) .fixedSize(horizontal: false, vertical: true) .padding(16) } @ViewBuilder func rankingDateSourcePickerView(showDateInLabel: Bool) -> some View { Section { Picker(selection: $currentRankSourceDate) { if currentRankSourceDate == nil { Text("inconnu").tag(nil as Date?) } ForEach(dates, id: \.self) { date in Text(date.monthYearFormatted).tag(date as Date?) } } label: { Text("Classement utilisé") if showDateInLabel { if let currentRankSourceDate { Text(currentRankSourceDate.monthYearFormatted) } else { Text("Choisir le mois") } } } .pickerStyle(.menu) } } private func _addPlayerSex() -> Int { switch tournament.tournamentCategory { case .men: return 1 case .women: return 0 case .mix: return 1 } } private func _filterOption() -> PlayerFilterOption { switch tournament.tournamentCategory { case .men: return .male case .women: return .female case .mix: return .all } } @ViewBuilder private func _inscriptionTipsView() -> some View { List { Section { TipView(fileTip) { action in if action.id == "website" { UIApplication.shared.open(SourceFileManager.beachPadel) } else if action.id == "add-team-file" { presentImportView = true } } .tipStyle(tint: nil) } Section { TipView(pasteTip) { action in if let paste = UIPasteboard.general.string { self.pasteString = paste } } .tipStyle(tint: nil) } Section { TipView(searchTip) { action in presentPlayerCreation = true } .tipStyle(tint: nil) } Section { TipView(createTip) { action in presentPlayerSearch = true } .tipStyle(tint: nil) } Section { ContentUnavailableView("Aucune équipe", systemImage: "person.2.slash", description: Text("Vous n'avez encore aucune équipe dans votre liste d'attente.")) } _rankHandlerView() } } @ViewBuilder private func _rankHandlerView() -> some View { if let mostRecentDate = SourceFileManager.shared.lastDataSourceDate(), let currentRankSourceDate, currentRankSourceDate < mostRecentDate, tournament.hasEnded() == false { Section { TipView(rankUpdateTip) { action in self.currentRankSourceDate = mostRecentDate } .tipStyle(tint: nil) } rankingDateSourcePickerView(showDateInLabel: false) } else if tournament.rankSourceDate == nil { rankingDateSourcePickerView(showDateInLabel: false) } } private func _informationView(count: Int) -> some View { Section { NavigationLink { InscriptionInfoView() .environment(tournament) } label: { LabeledContent { Text(tournament.registrationIssues().formatted()).font(.largeTitle) } label: { Text("Problèmes détéctés") if let closedRegistrationDate = tournament.closedRegistrationDate { Text("clôturé le " + closedRegistrationDate.formatted()) } } } } header: { Text(count.formatted() + "/" + tournament.teamCount.formatted() + " paires inscrites") } } @ViewBuilder private func _relatedTips() -> some View { if pasteString == nil && createdPlayerIds.isEmpty && tournament.unsortedTeams().count >= tournament.teamCount && tournament.unsortedPlayers().filter({ $0.source == .beachPadel }).isEmpty { Section { TipView(padelBeachExportTip) { action in if action.id == "more-info-export" { isLearningMore = true } if action.id == "padel-beach" { UIApplication.shared.open(SourceFileManager.beachPadel) } } .tipStyle(tint: nil) } Section { TipView(padelBeachImportTip) { action in if action.id == "more-info-import" { presentImportView = true } } .tipStyle(tint: nil) } } if tournament.tournamentCategory == .men && tournament.femalePlayers().isEmpty == false { Section { TipView(inscriptionManagerWomanRankTip) .tipStyle(tint: nil) } } Section { TipView(slideToDeleteTip) .tipStyle(tint: nil) } } private func _searchSource() -> String? { selectionSearchField ?? pasteString } private func _pastePredicate(pasteField: String, mostRecentDate: Date?) -> NSPredicate? { let text = pasteField.canonicalVersion let nameComponents = text.components(separatedBy: .whitespacesAndNewlines).compactMap { $0.isEmpty ? nil : $0 }.filter({ $0 != "de" && $0 != "la" && $0 != "le" && $0.count > 1 }) var andPredicates = [NSPredicate]() var orPredicates = [NSPredicate]() //self.wordsCount = nameComponents.count if _filterOption() == .male { andPredicates.append(NSPredicate(format: "male == YES")) } else 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 = nameComponents.pairs().map { return NSPredicate(format: "(firstName contains[cd] %@ AND lastName contains[cd] %@) OR (firstName contains[cd] %@ AND lastName contains[cd] %@)", $0, $1, $1, $0) } } else { orPredicates = nameComponents.map { NSPredicate(format: "firstName contains[cd] %@ OR lastName contains[cd] %@", $0,$0) } } let matches = text.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 } private func _currentSelection() -> Set { var currentSelection = Set() createdPlayerIds.compactMap { id in fetchPlayers.first(where: { id == $0.license }) }.forEach { player in let player = PlayerRegistration(importedPlayer: player) player.setWeight(in: tournament) currentSelection.insert(player) } createdPlayerIds.compactMap { id in createdPlayers.first(where: { id == $0.id }) }.forEach { currentSelection.insert($0) } return currentSelection } private func _createTeam() { let players = _currentSelection() let team = tournament.addTeam(players) try? dataStore.teamRegistrations.addOrUpdate(instance: team) try? dataStore.playerRegistrations.addOrUpdate(contentOfs: players) createdPlayers.removeAll() createdPlayerIds.removeAll() pasteString = nil } private func _updateTeam() { guard let editedTeam else { return } let players = _currentSelection() editedTeam.updatePlayers(players) try? dataStore.teamRegistrations.addOrUpdate(instance: editedTeam) try? dataStore.playerRegistrations.addOrUpdate(contentOfs: players) createdPlayers.removeAll() createdPlayerIds.removeAll() pasteString = nil self.editedTeam = nil } private func _buildingTeamView() -> some View { List(selection: $createdPlayerIds) { if let pasteString { Section { Text(pasteString) } footer: { HStack { Text("contenu du presse-papier") Spacer() Button("effacer", role: .destructive) { self.pasteString = nil self.createdPlayers.removeAll() self.createdPlayerIds.removeAll() } .buttonStyle(.borderless) } } } Section { ForEach(createdPlayerIds.sorted(), id: \.self) { id in if let p = createdPlayers.first(where: { $0.id == id }) { PlayerView(player: p).tag(p.id) } if let p = fetchPlayers.first(where: { $0.license == id }) { ImportedPlayerView(player: p).tag(p.license!) } } } if editedTeam == nil { if createdPlayerIds.isEmpty { RowButtonView("Bloquer une place") { _createTeam() } } else { RowButtonView("Ajouter l'équipe") { _createTeam() } } } else { RowButtonView("Modifier l'équipe") { _updateTeam() } } if let pasteString { if fetchPlayers.isEmpty { ContentUnavailableView { Label("Aucun résultat", systemImage: "person.2.slash") } description: { Text("Aucun joueur classé n'a été trouvé dans ce message.") } actions: { RowButtonView("Créer un joueur non classé") { presentPlayerCreation = true } RowButtonView("Effacer cette recherche") { self.pasteString = nil } } } else { Section { ForEach(fetchPlayers.sorted(by: { $0.hitForSearch(pasteString) > $1.hitForSearch(pasteString) })) { player in ImportedPlayerView(player: player).tag(player.license!) } } header: { Text(fetchPlayers.count.formatted() + " résultat" + fetchPlayers.count.pluralSuffix) } } } } .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 createdPlayerIds.insert(player.license!) } autoSelect = false } } .environment(\.editMode, Binding.constant(EditMode.active)) } private var count: Int { return fetchPlayers.filter { $0.hitForSearch(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 } } else { return 2 } return 1 } @ViewBuilder private func _sortingTypePickerView() -> some View { @Bindable var tournament = tournament Picker(selection: $tournament.teamSorting) { ForEach(TeamSortingType.allCases) { Text($0.localizedLabel()).tag($0) } } label: { } } @ViewBuilder private func _prioritizeClubMembersButton() -> some View { @Bindable var tournament = tournament if let federalClub = tournament.club() { Menu { Picker(selection: $tournament.prioritizeClubMembers) { Text("Oui").tag(true) Text("Non").tag(false) } label: { } .labelsHidden() Divider() NavigationLink { ClubsView() { club in if let event = tournament.eventObject { event.club = club.id try? dataStore.events.addOrUpdate(instance: event) } else { let event = Event(club: club.id) tournament.event = event.id try? dataStore.events.addOrUpdate(instance: event) } _save() } } label: { Text("Changer de club") } } label: { Text("Membres prioritaires") Text(federalClub.acronym) } Divider() } else { NavigationLink { ClubsView() { club in if let event = tournament.eventObject { event.club = club.id try? dataStore.events.addOrUpdate(instance: event) } else { let event = Event(club: club.id) tournament.event = event.id try? dataStore.events.addOrUpdate(instance: event) } _save() } } label: { Text("Identifier le club") } Divider() } } private func _teamFooterView(_ team: TeamRegistration) -> some View { HStack { if let formattedRegistrationDate = team.formattedInscriptionDate() { Text(formattedRegistrationDate) } Spacer() _teamMenuOptionView(team) } } private func _teamMenuOptionView(_ team: TeamRegistration) -> some View { Menu { Section { Button("Changer les joueurs") { editedTeam = team team.unsortedPlayers().forEach { player in createdPlayers.insert(player) createdPlayerIds.insert(player.id) } } Divider() NavigationLink { EditingTeamView(team: team) .environment(tournament) } label: { Text("Éditer une donnée de l'équipe") } Divider() Toggle(isOn: .init(get: { return team.wildCardBracket }, set: { value in team.resetPositions() team.wildCardGroupStage = false team.walkOut = false team.wildCardBracket = value try? dataStore.teamRegistrations.addOrUpdate(instance: team) })) { Label("Wildcard Tableau", systemImage: team.wildCardBracket ? "circle.inset.filled" : "circle") } Toggle(isOn: .init(get: { return team.wildCardGroupStage }, set: { value in team.resetPositions() team.wildCardBracket = false team.walkOut = false team.wildCardGroupStage = value try? dataStore.teamRegistrations.addOrUpdate(instance: team) })) { Label("Wildcard Poule", systemImage: team.wildCardGroupStage ? "circle.inset.filled" : "circle") } Divider() Toggle(isOn: .init(get: { return team.walkOut }, set: { value in team.resetPositions() team.wildCardBracket = false team.wildCardGroupStage = false team.walkOut = value try? dataStore.teamRegistrations.addOrUpdate(instance: team) })) { Label("WO", systemImage: team.walkOut ? "circle.inset.filled" : "circle") } Divider() Button(role: .destructive) { try? dataStore.teamRegistrations.delete(instance: team) } label: { LabelDelete() } // } header: { // Text(team.teamLabel(.short)) } } label: { LabelOptions().labelStyle(.titleOnly) } } private func _save() { try? dataStore.tournaments.addOrUpdate(instance: tournament) } } #Preview { NavigationStack { InscriptionManagerView(tournament: Tournament.mock()) .environment(Tournament.mock()) } }