From 708e0aa48113e690ec5ff52b8be4d79710431117 Mon Sep 17 00:00:00 2001 From: Raz Date: Sun, 8 Sep 2024 20:47:31 +0200 Subject: [PATCH] wip around me tab --- PadelClub.xcodeproj/project.pbxproj | 4 + .../Data/Federal/FederalTournament.swift | 13 +- .../Federal/FederalTournamentHolder.swift | 1 + PadelClub/Data/Tournament.swift | 4 + .../Utils/Network/NetworkFederalService.swift | 60 ++ PadelClub/ViewModel/AgendaDestination.swift | 42 +- .../ViewModel/FederalDataViewModel.swift | 27 +- PadelClub/ViewModel/Selectable.swift | 5 + .../GenericDestinationPickerView.swift | 9 +- .../Navigation/Agenda/ActivityView.swift | 179 +++-- .../Navigation/Agenda/EventListView.swift | 4 +- .../Agenda/TournamentLookUpView.swift | 637 ++++++++++++++++++ .../Views/Shared/TournamentFilterView.swift | 79 ++- 13 files changed, 964 insertions(+), 100 deletions(-) create mode 100644 PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index eea28e6..8fcbf80 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -247,6 +247,7 @@ FFCFC0182BBC5A6800B82851 /* SetLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFCFC0172BBC5A6800B82851 /* SetLabelView.swift */; }; FFCFC01A2BBC5A8500B82851 /* MatchTypeSmallSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFCFC0192BBC5A8500B82851 /* MatchTypeSmallSelectionView.swift */; }; FFCFC01C2BBC5AAA00B82851 /* SetDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFCFC01B2BBC5AAA00B82851 /* SetDescriptor.swift */; }; + FFD655D82C8DE27400E5B35E /* TournamentLookUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD655D72C8DE27400E5B35E /* TournamentLookUpView.swift */; }; FFD783FF2B91BA42000F62A6 /* PadelClubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD783FE2B91BA42000F62A6 /* PadelClubView.swift */; }; FFDB1C6D2BB2A02000F1E467 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFDB1C6C2BB2A02000F1E467 /* AppSettings.swift */; }; FFDB1C732BB2CFE900F1E467 /* MySortDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFDB1C722BB2CFE900F1E467 /* MySortDescriptor.swift */; }; @@ -593,6 +594,7 @@ FFCFC0172BBC5A6800B82851 /* SetLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetLabelView.swift; sourceTree = ""; }; FFCFC0192BBC5A8500B82851 /* MatchTypeSmallSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchTypeSmallSelectionView.swift; sourceTree = ""; }; FFCFC01B2BBC5AAA00B82851 /* SetDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDescriptor.swift; sourceTree = ""; }; + FFD655D72C8DE27400E5B35E /* TournamentLookUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentLookUpView.swift; sourceTree = ""; }; FFD783FE2B91BA42000F62A6 /* PadelClubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadelClubView.swift; sourceTree = ""; }; FFD784002B91BF79000F62A6 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; FFDB1C6C2BB2A02000F1E467 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; @@ -1291,6 +1293,7 @@ FF82CFC82B9132AF00B0CAF2 /* ActivityView.swift */, FF59FFB22B90EFAC0061EFF9 /* EventListView.swift */, FF5D0D8A2BB4D1E3005CB568 /* CalendarView.swift */, + FFD655D72C8DE27400E5B35E /* TournamentLookUpView.swift */, ); path = Agenda; sourceTree = ""; @@ -1557,6 +1560,7 @@ FF1CBC1B2BB53D1F0036DAAB /* FederalTournament.swift in Sources */, FF8F26412BADFC8700650388 /* TournamentInitView.swift in Sources */, C4A47D8A2B7BBB6500ADC637 /* SubscriptionView.swift in Sources */, + FFD655D82C8DE27400E5B35E /* TournamentLookUpView.swift in Sources */, FF1DC5572BAB3AED00FD8220 /* ClubsView.swift in Sources */, FFE103122C366E5900684FC9 /* ImagePickerView.swift in Sources */, FF8F264F2BAE0B9600650388 /* MatchTypeSelectionView.swift in Sources */, diff --git a/PadelClub/Data/Federal/FederalTournament.swift b/PadelClub/Data/Federal/FederalTournament.swift index 0a473ee..bd7e342 100644 --- a/PadelClub/Data/Federal/FederalTournament.swift +++ b/PadelClub/Data/Federal/FederalTournament.swift @@ -126,10 +126,22 @@ struct FederalTournament: Identifiable, Codable { ?? [] } + var federalClub: FederalClub? { + if let codeClub { + return FederalClub(federalClubCode: codeClub, federalClubName: clubLabel()) + } else { + return nil + } + } + var shareMessage: String { [libelle, dateDebut?.formatted(date: .complete, time: .omitted)].compactMap({$0}).joined(separator: "\n") + "\n" } + var japMessage: String { + [nomClub, jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, installation?.telephone].compactMap({$0}).joined(separator: ";") + } + func validForSearch(_ searchText: String, scope: FederalTournamentSearchScope) -> Bool { var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current) trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ") @@ -156,7 +168,6 @@ extension FederalTournament: FederalTournamentHolder { // var importedId: Int { id } var holderId: String { id.string } - func clubLabel() -> String { nomClub ?? villeEngagement ?? installation?.nom ?? "" } diff --git a/PadelClub/Data/Federal/FederalTournamentHolder.swift b/PadelClub/Data/Federal/FederalTournamentHolder.swift index 4350d27..c5181a2 100644 --- a/PadelClub/Data/Federal/FederalTournamentHolder.swift +++ b/PadelClub/Data/Federal/FederalTournamentHolder.swift @@ -11,6 +11,7 @@ protocol FederalTournamentHolder { var holderId: String { get } var startDate: Date { get } var endDate: Date? { get } + var codeClub: String? { get } var tournaments: [any TournamentBuildHolder] { get } func clubLabel() -> String func subtitleLabel() -> String diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index 1d034d6..a3aa857 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -2115,6 +2115,10 @@ extension Tournament: Hashable { } extension Tournament: FederalTournamentHolder { + var codeClub: String? { + club()?.code + } + var holderId: String { id } func clubLabel() -> String { diff --git a/PadelClub/Utils/Network/NetworkFederalService.swift b/PadelClub/Utils/Network/NetworkFederalService.swift index 666b049..7933f55 100644 --- a/PadelClub/Utils/Network/NetworkFederalService.swift +++ b/PadelClub/Utils/Network/NetworkFederalService.swift @@ -194,4 +194,64 @@ recherche_type=club&club[autocomplete][value_container][value_field]=\(codeClub. print("no data found in html") } } + + func getAllFederalTournaments(sortingOption: String, page: Int, startDate: Date, endDate: Date, city: String, distance: Double, categories: [TournamentCategory], levels: [TournamentLevel], lat: String?, lng: String?, ages: [FederalTournamentAge], types: [FederalTournamentType], nationalCup: Bool) async throws -> [HttpCommand] { + + var cityParameter = "" + var searchType = "ligue" + if city.trimmed.isEmpty == false { + searchType = "ville" + cityParameter = city + } + + var levelsParameter = "" + if levels.isEmpty == false { + levelsParameter = levels.map { "categorie_tournoi[\($0.localizedLabel)]=\($0.localizedLabel)" }.joined(separator: "&") + "&" + } + + var categoriesParameter = "" + if categories.isEmpty == false { + categoriesParameter = categories.map { "epreuve[\($0.requestLabel)]=\($0.requestLabel)" }.joined(separator: "&") + "&" + } + + var agesParameter = "" + if ages.isEmpty == false { + agesParameter = ages.map { "categorie_age[\($0.rawValue)]=\($0.rawValue)" }.joined(separator: "&") + "&" + } + + var typesParameter = "" + if types.isEmpty == false { + typesParameter = types.map { "type[\($0.rawValue.capitalized)]=\($0.rawValue.capitalized)" }.joined(separator: "&") + "&" + } + + var npc = "" + if nationalCup { + npc = "&tournoi_npc=1" + } + + let parameters = """ +recherche_type=\(searchType)&ville%5Bautocomplete%5D%5Bcountry%5D=fr&ville%5Bautocomplete%5D%5Btextfield%5D=&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Bvalue_field%5D=\(cityParameter)&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blabel_field%5D=\(cityParameter)&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blat_field%5D=\(lat ?? "")&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blng_field%5D=\(lng ?? "")&ville%5Bdistance%5D%5Bvalue_field%5D=\(Int(distance))&club%5Bautocomplete%5D%5Btextfield%5D=&club%5Bautocomplete%5D%5Bvalue_container%5D%5Bvalue_field%5D=&club%5Bautocomplete%5D%5Bvalue_container%5D%5Blabel_field%5D=&pratique=PADEL&date%5Bstart%5D=\(startDate.twoDigitsYearFormatted)&date%5Bend%5D=\(endDate.twoDigitsYearFormatted)&\(categoriesParameter)\(levelsParameter)\(agesParameter)\(typesParameter)\(npc)&page=\(page)&sort=\(sortingOption)&form_build_id=\(formId)&form_id=recherche_tournois_form&_triggering_element_name=submit_page&_triggering_element_value=Submit+page +""" + let postData = parameters.data(using: .utf8) + + var request = URLRequest(url: URL(string: "https://tenup.fft.fr/system/ajax")!,timeoutInterval: Double.infinity) + request.addValue("application/json, text/javascript, */*; q=0.01", forHTTPHeaderField: "Accept") + request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language") + request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding") + request.addValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type") + request.addValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.addValue("https://tenup.fft.fr", forHTTPHeaderField: "Origin") + request.addValue("keep-alive", forHTTPHeaderField: "Connection") + request.addValue("https://tenup.fft.fr/recherche/tournois", forHTTPHeaderField: "Referer") + request.addValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.addValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site") + + request.httpMethod = "POST" + request.httpBody = postData + + + return try await runTenupTask(request: request) + } + } diff --git a/PadelClub/ViewModel/AgendaDestination.swift b/PadelClub/ViewModel/AgendaDestination.swift index 3708e3d..abe2126 100644 --- a/PadelClub/ViewModel/AgendaDestination.swift +++ b/PadelClub/ViewModel/AgendaDestination.swift @@ -18,12 +18,8 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable { case activity case history case tenup + case around - enum ViewStyle { - case list - case calendar - } - var localizedTitleKey: String { switch self { case .activity: @@ -32,6 +28,8 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable { return "Terminé" case .tenup: return "Tenup" + case .around: + return "Autour" } } @@ -39,14 +37,12 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable { localizedTitleKey } - var systemImage: String { + func systemImage() -> String? { switch self { - case .activity: - return "squares.leading.rectangle" - case .history: - return "book.closed" - case .tenup: - return "tennisball" + case .around: + return "location.magnifyingglass" + default: + return nil } } @@ -58,6 +54,9 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable { DataStore.shared.tournaments.filter { $0.endDate != nil && FederalDataViewModel.shared.isTournamentValidForFilters($0) }.count case .tenup: FederalDataViewModel.shared.filteredFederalTournaments.map { $0.tournaments.count }.reduce(0,+) + case .around: + nil + } } @@ -84,6 +83,25 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable { } else { return nil } + case .around: + return nil } } } + + +enum ViewStyle { + case list + case calendar +} + +struct ViewStyleKey: EnvironmentKey { + static let defaultValue: ViewStyle = .list +} + +extension EnvironmentValues { + var viewStyle: ViewStyle { + get { self[ViewStyleKey.self] } + set { self[ViewStyleKey.self] = newValue } + } +} diff --git a/PadelClub/ViewModel/FederalDataViewModel.swift b/PadelClub/ViewModel/FederalDataViewModel.swift index 387dc0a..0c3b349 100644 --- a/PadelClub/ViewModel/FederalDataViewModel.swift +++ b/PadelClub/ViewModel/FederalDataViewModel.swift @@ -13,11 +13,13 @@ class FederalDataViewModel { static let shared = FederalDataViewModel() var federalTournaments: [FederalTournament] = [] + var searchedFederalTournaments: [FederalTournament] = [] var levels: Set = Set() var categories: Set = Set() var ageCategories: Set = Set() var selectedClubs: Set = Set() var id: UUID = UUID() + var searchAttemptCount: Int = 0 func filterStatus() -> String { var labels: [String] = [] @@ -33,6 +35,14 @@ class FederalDataViewModel { return labels.joined(separator: ", ") } + var searchedClubs: [FederalClub] { + searchedFederalTournaments.compactMap { ft in + ft.federalClub + }.uniqued { fc in + fc.federalClubCode + }.sorted(by: \.federalClubName) + } + func selectedClub() -> Club? { if selectedClubs.isEmpty == false { return DataStore.shared.clubs.first(where: { $0.code == selectedClubs.first! }) @@ -53,8 +63,16 @@ class FederalDataViewModel { (levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty) == false } - var filteredFederalTournaments: [FederalTournament] { - federalTournaments.filter({ tournament in + var filteredFederalTournaments: [FederalTournamentHolder] { + filteredFederalTournaments(from: federalTournaments) + } + + var filteredSearchedFederalTournaments: [FederalTournamentHolder] { + filteredFederalTournaments(from: searchedFederalTournaments) + } + + func filteredFederalTournaments(from tournaments: [any FederalTournamentHolder]) -> [FederalTournamentHolder] { + tournaments.filter({ tournament in (levels.isEmpty || tournament.tournaments.anySatisfy({ levels.contains($0.level) })) && (categories.isEmpty || tournament.tournaments.anySatisfy({ categories.contains($0.category) })) @@ -103,3 +121,8 @@ class FederalDataViewModel { } } +struct FederalClub: Identifiable { + var id: String { federalClubCode } + var federalClubCode: String + var federalClubName: String +} diff --git a/PadelClub/ViewModel/Selectable.swift b/PadelClub/ViewModel/Selectable.swift index e29e072..6734828 100644 --- a/PadelClub/ViewModel/Selectable.swift +++ b/PadelClub/ViewModel/Selectable.swift @@ -14,9 +14,14 @@ protocol Selectable { func badgeImage() -> Badge? func badgeValueColor() -> Color? func displayImageIfValueZero() -> Bool + func systemImage() -> String? } extension Selectable { + func systemImage() -> String? { + return nil + } + func displayImageIfValueZero() -> Bool { return false } diff --git a/PadelClub/Views/Components/GenericDestinationPickerView.swift b/PadelClub/Views/Components/GenericDestinationPickerView.swift index 999a564..7002dc9 100644 --- a/PadelClub/Views/Components/GenericDestinationPickerView.swift +++ b/PadelClub/Views/Components/GenericDestinationPickerView.swift @@ -38,8 +38,13 @@ struct GenericDestinationPickerView: Button { selectedDestination = destination } label: { - Text(destination.selectionLabel(index: index)) - .foregroundStyle(selectedDestination?.id == destination.id ? .white : .black) + if let systemImage = destination.systemImage() { + Image(systemName: systemImage) + .foregroundStyle(selectedDestination?.id == destination.id ? .white : .black) + } else { + Text(destination.selectionLabel(index: index)) + .foregroundStyle(selectedDestination?.id == destination.id ? .white : .black) + } } .padding() .background { diff --git a/PadelClub/Views/Navigation/Agenda/ActivityView.swift b/PadelClub/Views/Navigation/Agenda/ActivityView.swift index 09ac9c4..41d96d6 100644 --- a/PadelClub/Views/Navigation/Agenda/ActivityView.swift +++ b/PadelClub/Views/Navigation/Agenda/ActivityView.swift @@ -16,12 +16,13 @@ struct ActivityView: View { @State private var presentFilterView: Bool = false @State private var presentToolbar: Bool = false @State private var newTournament: Tournament? - @State private var viewStyle: AgendaDestination.ViewStyle = .list + @State private var viewStyle: ViewStyle = .list @State private var isGatheringFederalTournaments: Bool = false @State private var error: Error? @State private var uuid: UUID = UUID() @State private var presentClubSearchView: Bool = false @State private var quickAccessScreen: QuickAccessScreen? = nil + @State private var displaySearchView: Bool = false enum QuickAccessScreen : Identifiable, Hashable { case inscription(pasteString: String) @@ -67,6 +68,8 @@ struct ActivityView: View { return endedTournaments case .tenup: return federalDataViewModel.filteredFederalTournaments + case .around: + return federalDataViewModel.filteredSearchedFederalTournaments } } @@ -81,23 +84,39 @@ struct ActivityView: View { .buttonBorderShape(.capsule) } + @ViewBuilder + func _listView() -> some View { + switch navigation.agendaDestination! { + case .activity: + List { + EventListView(tournaments: runningTournaments, sortAscending: true) + } + case .history: + List { + EventListView(tournaments: endedTournaments, sortAscending: false) + } + case .tenup: + List { + EventListView(tournaments: federalDataViewModel.federalTournaments, sortAscending: true) + .id(uuid) + } + case .around: + List { + EventListView(tournaments: federalDataViewModel.searchedFederalTournaments, sortAscending: true) + .id(uuid) + } + } + } + + var body: some View { @Bindable var navigation = navigation NavigationStack(path: $navigation.path) { VStack(spacing: 0) { GenericDestinationPickerView(selectedDestination: $navigation.agendaDestination, destinations: AgendaDestination.allCases, nilDestinationIsValid: false) - List { - switch navigation.agendaDestination! { - case .activity: - EventListView(tournaments: runningTournaments, viewStyle: viewStyle, sortAscending: true) - case .history: - EventListView(tournaments: endedTournaments, viewStyle: viewStyle, sortAscending: false) - case .tenup: - EventListView(tournaments: federalDataViewModel.federalTournaments, viewStyle: viewStyle, sortAscending: true) - .id(uuid) - } - } + _listView() + .environment(\.viewStyle, viewStyle) .environment(federalDataViewModel) .overlay { if let error, navigation.agendaDestination == .tenup { @@ -119,11 +138,11 @@ struct ActivityView: View { ContentUnavailableView.search(text: searchText) } else if federalDataViewModel.areFiltersEnabled() { ContentUnavailableView { - Text("Aucun résultat") + Text("Aucun tournoi") } description: { - Text(federalDataViewModel.filterStatus()) + Text("Aucun tournoi ne correspond aux fitres que vous avez choisis : \(federalDataViewModel.filterStatus())") } actions: { - RowButtonView("supprimer le filtre") { + RowButtonView("modifier vos filtres") { federalDataViewModel.removeFilters() } .padding(.horizontal) @@ -137,6 +156,12 @@ struct ActivityView: View { //.searchable(text: $searchText) .onAppear { presentToolbar = true } .onDisappear { presentToolbar = false } + .sheet(isPresented: $displaySearchView) { + NavigationStack { + TournamentLookUpView() + .environment(federalDataViewModel) + } + } .sheet(item: $newTournament) { tournament in EventCreationView(tournaments: [tournament], selectedClub: federalDataViewModel.selectedClub()) .environment(navigation) @@ -164,52 +189,65 @@ struct ActivityView: View { } } .toolbar { - if presentToolbar { - //let _activityStatus = _activityStatus() - if federalDataViewModel.areFiltersEnabled() { - ToolbarItem(placement: .status) { - Text(federalDataViewModel.filterStatus()) + ToolbarItemGroup(placement: .topBarLeading) { + Button { + switch viewStyle { + case .list: + viewStyle = .calendar + case .calendar: + viewStyle = .list } + } label: { + Image(systemName: "calendar.circle") + .resizable() + .scaledToFit() + .frame(minHeight: 28) } + .symbolVariant(viewStyle == .calendar ? .fill : .none) - ToolbarItemGroup(placement: .topBarLeading) { - Button { - switch viewStyle { - case .list: - viewStyle = .calendar - case .calendar: - viewStyle = .list - } - } label: { - Image(systemName: "calendar.circle") - .resizable() - .scaledToFit() - .frame(minHeight: 28) - } - .symbolVariant(viewStyle == .calendar ? .fill : .none) - - Button { - presentFilterView.toggle() - } label: { - Image(systemName: "line.3.horizontal.decrease.circle") - .resizable() - .scaledToFit() - .frame(minHeight: 28) - } - .symbolVariant(federalDataViewModel.areFiltersEnabled() ? .fill : .none) - - _pasteView() + Button { + presentFilterView.toggle() + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + .resizable() + .scaledToFit() + .frame(minHeight: 28) } + .symbolVariant(federalDataViewModel.areFiltersEnabled() ? .fill : .none) - ToolbarItem(placement: .topBarTrailing) { - Button { - newTournament = Tournament.newEmptyInstance() - - } label: { - Image(systemName: "plus.circle.fill") - .resizable() - .scaledToFit() - .frame(minHeight: 28) + _pasteView() + } + + ToolbarItem(placement: .topBarTrailing) { + Button { + newTournament = Tournament.newEmptyInstance() + + } label: { + Image(systemName: "plus.circle.fill") + .resizable() + .scaledToFit() + .frame(minHeight: 28) + } + } + + if presentToolbar { + if navigation.agendaDestination == .around, federalDataViewModel.searchedFederalTournaments.isEmpty == false { + let filteredSearchedFederalTournaments = federalDataViewModel.filteredSearchedFederalTournaments + + let status : String = filteredSearchedFederalTournaments.count.formatted() + " tournoi" + filteredSearchedFederalTournaments.count.pluralSuffix + + ToolbarItem(placement: .bottomBar) { + VStack { + Text(status) + FooterButtonView("modifier les critères de recherche") { + displaySearchView = true + } + } + .font(.footnote) + } + } else if federalDataViewModel.areFiltersEnabled() { + ToolbarItem(placement: .status) { + Text(federalDataViewModel.filterStatus()) } } } @@ -298,6 +336,8 @@ struct ActivityView: View { _endedEmptyView() case .tenup: _tenupEmptyView() + case .around: + _searchTenupEmptyView() } } @@ -363,6 +403,33 @@ struct ActivityView: View { } } + @ViewBuilder + private func _searchTenupEmptyView() -> some View { + if federalDataViewModel.searchAttemptCount == 0 { + ContentUnavailableView { + Label("Recherche de tournoi", systemImage: "magnifyingglass") + } description: { + Text("Chercher les tournois autour de vous pour vous aidez à mieux selectionner ce que vous pouvez proposer.") + } actions: { + RowButtonView("Lancer la recherche") { + displaySearchView = true + } + .padding() + } + } else { + ContentUnavailableView { + Label("Aucun tournoi", systemImage: "shield.slash") + } description: { + Text("Aucun tournoi ne correspond aux critères sélectionnés.") + } actions: { + RowButtonView("Modifier vos critères de recherche") { + displaySearchView = true + } + .padding() + } + } + } + } //#Preview { diff --git a/PadelClub/Views/Navigation/Agenda/EventListView.swift b/PadelClub/Views/Navigation/Agenda/EventListView.swift index 4c89482..f146b31 100644 --- a/PadelClub/Views/Navigation/Agenda/EventListView.swift +++ b/PadelClub/Views/Navigation/Agenda/EventListView.swift @@ -12,13 +12,13 @@ struct EventListView: View { @EnvironmentObject var dataStore: DataStore @Environment(NavigationViewModel.self) var navigation: NavigationViewModel @Environment(FederalDataViewModel.self) var federalDataViewModel: FederalDataViewModel + @Environment(\.viewStyle) var viewStyle let tournaments: [FederalTournamentHolder] - let viewStyle: AgendaDestination.ViewStyle let sortAscending: Bool var body: some View { - let groupedTournamentsByDate = Dictionary(grouping: navigation.agendaDestination == .tenup ? federalDataViewModel.filteredFederalTournaments : tournaments) { $0.startDate.startOfMonth } + let groupedTournamentsByDate = Dictionary(grouping: federalDataViewModel.filteredFederalTournaments(from: tournaments)) { $0.startDate.startOfMonth } switch viewStyle { case .list: ForEach(groupedTournamentsByDate.keys.sorted(by: sortAscending ? { $0 < $1 } : { $0 > $1 }), id: \.self) { section in diff --git a/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift b/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift new file mode 100644 index 0000000..188f08e --- /dev/null +++ b/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift @@ -0,0 +1,637 @@ +// +// TournamentLookUpView.swift +// PadelClub +// +// Created by razmig on 08/09/2024. +// + +import SwiftUI +import CoreLocation +import CoreLocationUI + +struct TournamentLookUpView: View { + @Environment(FederalDataViewModel.self) var federalDataViewModel: FederalDataViewModel + @StateObject var locationManager = LocationManager() + @Environment(\.dismiss) private var dismiss + + @State private var searchField: String = "" + @State private var sectionedTournaments: [String: [FederalTournament]] = [:] + @State private var dayPeriod: DayPeriod = .all + @State private var duration: Int = 3 + + @State var page: Int = 0 + @State var total: Int = 0 + + @State private var tournamentCategories = Set() + @State private var tournamentLevels = Set() + @State private var tournamentAges = Set() + @State private var tournamentTypes = Set() + @State private var searching: Bool = false + @State private var startDate: Date = Date() + @State private var endDate: Date = Calendar.current.date(byAdding: .month, value: 3, to: Date())! + @AppStorage("lastCity") private var city: String = "" + @State private var ligue: String = "" + @AppStorage("lastDistance") private var distance: Double = 30 + @AppStorage("lastSortingOption") private var sortingOption: String = "_DIST_" + @State private var requestedToGetAllPages: Bool = false + @AppStorage("lastNationalCup") private var nationalCup: Bool = false + @State private var revealSearchParameters: Bool = true + @State private var searchScope = FederalTournamentSearchScope.all + + var tournaments: [FederalTournament] { + federalDataViewModel.searchedFederalTournaments + } + + func canShowTournament(_ tournament: FederalTournament) -> Bool { + guard tournament.dayDuration <= duration else { return false } + guard (tournament.dayPeriod == dayPeriod && dayPeriod != .all) || dayPeriod == .all else { return false } + if searchField.isEmpty { + return true + } else { + return tournament.validForSearch(searchField, scope: searchScope) + } + } + + var body: some View { + List { + searchParametersView + + if tournaments.isEmpty == false && tournaments.count < total && total >= 200 && requestedToGetAllPages == false { + Section { + Text("Il y a beacoup de tournois pour cette requête, êtes-vous sûr de vouloir tout récupérer ? Sinon essayez d'affiner votre recherche.") + Button { + requestedToGetAllPages = true + page += 1 + searching = true + Task { + await getNewPage() + searching = false + buildSectionedData() + } + } label: { + Label("Tout voir", systemImage: "arrow.down.circle") + } + } + } + } + .toolbarBackground(.visible, for: .bottomBar, .navigationBar) + .navigationTitle("Chercher un tournoi") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: locationManager.city, perform: { newValue in + if let newValue, city.isEmpty { + city = newValue + } + }) + .toolbarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .bottomBar) { + if revealSearchParameters { + FooterButtonView("Lancer la recherche") { + runSearch() + } + .disabled(searching) + } else if searchField.isEmpty == false && searchScope != .all { + let count = _totalVisibleEpreuves().count + VStack { + Text(searchField) + .foregroundStyle(.secondary) + Text(count.formatted() + " tournoi" + count.pluralSuffix) + } + .font(.caption) + } else if searching { + HStack(spacing: 20) { + Spacer() + ProgressView() + if total > 0 { + let percent = Double(tournaments.count) / Double(total) + Text(percent.formatted(.percent.precision(.significantDigits(1...3))) + " en récupération de Tenup") + .font(.caption) + } + Spacer() + } + } else { + let count = _totalVisibleEpreuves().count + Text(count.formatted() + " tournoi" + count.pluralSuffix) + .font(.caption) + } + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + if tournaments.isEmpty == false { + Section { + let preview = SharePreview(Text("Ma recherche de tournois"), icon: Image("PadelClub_logo_fondclair_transparent")) + ShareLink(item: renderedImage ?? Image(systemName: "photo"), preview: preview) { + if renderedImage == nil { + ProgressView() + } else { + Label("Par image (20max)", systemImage: "square.and.arrow.up") + .labelStyle(.titleAndIcon) + } + } + ShareLink(item: pastedTournaments) { + Label("Par texte", systemImage: "square.and.arrow.up") + .labelStyle(.titleAndIcon) + } + ShareLink(item: japList) { + Label("JAP liste", systemImage: "square.and.arrow.up") + .labelStyle(.titleAndIcon) + } + } header: { + Text("Partager les résultats") + } + } + Divider() + Button { + tournamentLevels = Set() + tournamentCategories = Set() + city = "" + locationManager.location = nil + locationManager.city = nil + distance = 30 + startDate = Date() + endDate = Calendar.current.date(byAdding: .month, value: 3, to: Date())! + sortingOption = "_DIST_" + revealSearchParameters = true + } label: { + Text("Ré-initialiser la recherche") + } + } label: { + Label("Options", systemImage: "ellipsis.circle") + } + } + } + } + var pastedTournaments: String { + tournaments.map { $0.shareMessage }.joined() + } + + var japList: String { + Set(tournaments.map { $0.japMessage }).joined(separator: "\n") + } + + private func isTypeLookedAfter(_ type: any TournamentBuildHolder) -> Bool { + if levels.contains(where: { level in + type.level == level + }) || levels.isEmpty { + if categories.contains(where: { category in + type.category == category + }) || categories.isEmpty { + + return true + } + } + return false + } + + + @Environment(\.displayScale) var displayScale + @State private var renderedImage: Image? + + @MainActor + func render() { + let renderer = ImageRenderer(content: tournamentsView) + renderer.scale = displayScale + renderer.isOpaque = true + if let uiImage = renderer.uiImage { + renderedImage = Image(uiImage: uiImage) + } + } + + @ViewBuilder + private var tournamentsView: some View { + let tournaments = tournaments.prefix(20) + VStack { + ForEach(tournaments.indices, id: \.self) { tournamentIndex in + let tournament = tournaments[tournamentIndex] + HStack(alignment: .center) { + VStack(alignment: .leading) { + Text(tournament.libelle ?? "unknown").font(.headline) + if let club = tournament.nomClub { + Text(club) + .font(.footnote) + .lineLimit(1) + } + } + Spacer() + VStack(alignment: .trailing) { + if let startDate = tournament.dateDebut { + Text(startDate.monthYearFormatted) + HStack { + Text(startDate.formatted(.dateTime.weekday())) + Text(startDate.formatted(.dateTime.day())).font(.largeTitle) + } + } + if let distance = tournament.distanceEnMetres { + let measurement = Measurement(value: distance / 1000, unit: UnitLength.kilometers) + Text(measurement.formatted()).font(.caption) + } + } + } + .padding() + .foregroundColor(Color.black) + .background { + tournamentIndex%2 == 0 ? Color.mint : Color.cyan + } + } + } + .padding() + } + + + private var clubsFound: [String] { + Set(tournaments.compactMap { $0.nomClub }).sorted() + } + + private var liguesFound: [String] { + Set(tournaments.compactMap { $0.nomLigue }).sorted() + } + + private func runSearch() { + revealSearchParameters = false + total = 0 + page = 0 + federalDataViewModel.searchedFederalTournaments = [] + searching = true + requestedToGetAllPages = false + renderedImage = nil + federalDataViewModel.searchAttemptCount += 1 + Task { + await getNewPage() + searching = false + dismiss() + } + } + + func buildSectionedData() { + sectionedTournaments = FederalTournament.sectionedData(from: tournaments) + } + + private var distanceLimit: Measurement { + distanceLimit(distance: distance) + } + + private func distanceLimit(distance: Double) -> Measurement { + Measurement(value: distance, unit: .kilometers) + } + + private var categories: [TournamentCategory] { + tournamentCategories.compactMap { TournamentCategory(rawValue: $0) } + } + + private var levels: [TournamentLevel] { + tournamentLevels.compactMap { TournamentLevel(rawValue: $0) } + } + + private var ages: [FederalTournamentAge] { + tournamentAges.compactMap { FederalTournamentAge(rawValue: $0) } + } + + private var types: [FederalTournamentType] { + tournamentTypes.compactMap { FederalTournamentType(rawValue: $0) } + } + + func getNewPage() async { + do { + if NetworkFederalService.shared.formId.isEmpty { + await getNewBuildForm() + } else { + let commands = try await NetworkFederalService.shared.getAllFederalTournaments(sortingOption: sortingOption, page: page, startDate: startDate, endDate: endDate, city: city, distance: distance, categories: categories, levels: levels, lat: locationManager.location?.coordinate.latitude.formatted(.number.locale(Locale(identifier: "us"))), lng: locationManager.location?.coordinate.longitude.formatted(.number.locale(Locale(identifier: "us"))), ages: ages, types: types, nationalCup: nationalCup) + let resultCommand = commands.first(where: { $0.results != nil }) + if let newTournaments = resultCommand?.results?.items { + newTournaments.forEach { ft in + if tournaments.contains(where: { $0.id == ft.id }) == false { + federalDataViewModel.searchedFederalTournaments.append(ft) + } + } + } + if let count = resultCommand?.results?.nb_results { + print("count", count, total, tournaments.count, page) + total = count + + if renderedImage == nil { + render() + } + if tournaments.count < count && page < total / 30 { + if total < 200 || requestedToGetAllPages { + page += 1 + await MainActor.run() { + buildSectionedData() + } + await getNewPage() + } + } else { + print("finished") + } + } else { + print("total results not found") + } + } + } catch { + print("getNewPage", error) + await getNewBuildForm() + } + } + + func getNewBuildForm() async { + do { + try await NetworkFederalService.shared.getNewBuildForm() + await getNewPage() + } catch { + print("getNewBuildForm", error) + } + } + + @ViewBuilder + var searchContollerView: some View { + Section { + Button { + runSearch() + } label: { + HStack { + Label("Chercher un tournoi", systemImage: "magnifyingglass") + if searching { + Spacer() + ProgressView() + } + } + } + Button { + tournamentLevels = Set() + tournamentCategories = Set() + city = "" + locationManager.location = nil + locationManager.city = nil + dayPeriod = .all + duration = 3 + distance = 30 + startDate = Date() + endDate = Calendar.current.date(byAdding: .month, value: 3, to: Date())! + sortingOption = "_DIST_" + revealSearchParameters = true + } label: { + Label("Ré-initialiser la recherche", systemImage: "xmark.circle") + } + } + } + + @ViewBuilder + var searchParametersView: some View { + Section { + DatePicker("Début", selection: $startDate, displayedComponents: .date) + DatePicker("Fin", selection: $endDate, displayedComponents: .date) + + Picker(selection: $duration) { + Text("Aucune").tag(7) + Text(1.formatted()).tag(1) + Text(2.formatted()).tag(2) + Text(3.formatted()).tag(3) + } label: { + Text("Durée max (en jours)") + } + + Picker(selection: $dayPeriod) { + Text("N'importe").tag(DayPeriod.all) + Text("le weekend").tag(DayPeriod.weekend) + Text("la semaine").tag(DayPeriod.week) + } label: { + Text("En semaine ou week-end") + } + + HStack { + TextField("Ville", text: $city) + if let city = locationManager.city { + Divider() + Text(city).italic() + } + if locationManager.requestStarted { + ProgressView() + } else { + LocationButton { + locationManager.requestLocation() + } + .symbolVariant(.fill) + .foregroundColor (Color.white) + .cornerRadius (20) + .font(.system(size: 12)) + } + } + + Picker(selection: $distance) { + Text(distanceLimit(distance:30).formatted()).tag(30.0) + Text(distanceLimit(distance:50).formatted()).tag(50.0) + Text(distanceLimit(distance:60).formatted()).tag(60.0) + Text(distanceLimit(distance:90).formatted()).tag(90.0) + Text(distanceLimit(distance:150).formatted()).tag(150.0) + Text(distanceLimit(distance:200).formatted()).tag(200.0) + Text(distanceLimit(distance:400).formatted()).tag(400.0) + Text("Aucune").tag(3000.0) + } label: { + Text("Distance max") + } + + Picker(selection: $sortingOption) { + Text("Distance").tag("_DIST_") + Text("Date de début").tag("dateDebut+asc") + Text("Date de fin").tag("dateFin+asc") + } label: { + Text("Trier par") + } + + NavigationLink { + List(TournamentCategory.allCases, selection: $tournamentCategories) { type in + Text(type.localizedLabel()) + } + .navigationTitle("Catégories") + .environment(\.editMode, Binding.constant(EditMode.active)) + } label: { + HStack { + Text("Catégorie") + Spacer() + categoriesLabel + .foregroundStyle(.secondary) + } + } + + NavigationLink { + List(TournamentLevel.allCases, selection: $tournamentLevels) { type in + Text(type.localizedLabel()) + } + .navigationTitle("Niveaux") + .environment(\.editMode, Binding.constant(EditMode.active)) + } label: { + HStack { + Text("Niveau") + Spacer() + levelsLabel + .foregroundStyle(.secondary) + } + } + + NavigationLink { + List(FederalTournamentAge.allCases, selection: $tournamentAges) { type in + Text(type.localizedLabel()) + } + .navigationTitle("Limites d'âge") + .environment(\.editMode, Binding.constant(EditMode.active)) + } label: { + HStack { + Text("Limite d'âge") + Spacer() + if tournamentAges.isEmpty || tournamentAges.count == FederalTournamentAge.allCases.count { + Text("Tous les âges") + .foregroundStyle(.secondary) + } else { + Text(ages.map({ $0.localizedLabel()}).joined(separator: ", ")) + .foregroundStyle(.secondary) + } + } + } + + NavigationLink { + List(FederalTournamentType.allCases, selection: $tournamentTypes) { type in + Text(type.localizedLabel()) + } + .navigationTitle("Types de tournoi") + .environment(\.editMode, Binding.constant(EditMode.active)) + } label: { + HStack { + Text("Type de tournoi") + Spacer() + if tournamentTypes.isEmpty || tournamentTypes.count == FederalTournamentType.allCases.count { + Text("Tous les types") + .foregroundStyle(.secondary) + } else { + Text(types.map({ $0.localizedLabel()}).joined(separator: ", ")) + .foregroundStyle(.secondary) + } + } + } + + Picker(selection: $nationalCup) { + Text("N'importe").tag(false) + Text("Uniquement").tag(true) + } label: { + Text("Circuit National Padel Cup") + } + } header: { + Text("Critères de recherche") + } + .headerProminence(.increased) + .disabled(searching) + } + + var categoriesLabel: some View { + if tournamentCategories.isEmpty || tournamentCategories.count == TournamentCategory.allCases.count { + Text("Toutes les catégories") + } else { + Text(categories.map({ $0.localizedLabel() }).joined(separator: ", ")) + } + } + + var levelsLabel: some View { + if tournamentLevels.isEmpty || tournamentLevels.count == TournamentLevel.allCases.count { + Text("Tous les niveaux") + } else { + Text(levels.map({ $0.localizedLabel() }).joined(separator: ", ")) + } + } + + @ViewBuilder + var searchParametersSummaryView: some View { + VStack(alignment: .leading) { + HStack { + Text("Lieu") + Spacer() + Text(city) + if distance >= 3000 { + Text("sans limite de distance") + } else { + Text("à moins de " + distanceLimit.formatted()) + } + } + HStack { + Text("Période") + Spacer() + Text("Du") + Text(startDate.twoDigitsYearFormatted) + Text("Au") + Text(endDate.twoDigitsYearFormatted) + } + HStack { + Text("Niveau") + Spacer() + levelsLabel + } + HStack { + Text("Catégorie") + Spacer() + categoriesLabel + } + + HStack { + Text("Tri") + Spacer() + Text(sortingOptionLabel) + } + } + } + + var sortingOptionLabel: String { + switch sortingOption { + case "_DIST_": return "Distance" + case "dateDebut+asc": return "Date de début" + case "dateFin+asc": return "Date de fin" + default: + return "Distance" + } + } + + + func _totalVisibleTournaments(_ date: Date? = nil) -> [FederalTournament] { + if let date { + if let tournaments = sectionedTournaments[URL.importDateFormatter.string(from: date)] { + let allTournaments = tournaments.filter({ canShowTournament($0) }).filter({ tournament in + if tournament.tournaments.count > 1 { + return tournament.tournaments.anySatisfy { isTypeLookedAfter($0) } + } else { + return true + } + }) + return allTournaments + } else { + return [] + } + } else { + let allTournaments = sectionedTournaments.values.flatMap({ $0 }).filter({ canShowTournament($0) }).filter({ tournament in + if tournament.tournaments.count > 1 { + return tournament.tournaments.anySatisfy { isTypeLookedAfter($0) } + } else { + return true + } + }) + return allTournaments + } + } + + func _totalVisibleEpreuves(_ date: Date? = nil) -> [any TournamentBuildHolder] { + if let date { + if let tournaments = sectionedTournaments[URL.importDateFormatter.string(from: date)] { + let allTournaments = tournaments + .filter({ canShowTournament($0) }) + .compactMap({ $0.tournaments }) + .flatMap({ $0 }) + .filter({ isTypeLookedAfter($0) }) + return allTournaments + } else { + return [] + } + } else { + let allTournaments = sectionedTournaments.values.flatMap({ $0 }) + .filter({ canShowTournament($0) }) + .compactMap({ $0.tournaments }) + .flatMap({ $0 }) + .filter({ isTypeLookedAfter($0) }) + return allTournaments + } + } +} diff --git a/PadelClub/Views/Shared/TournamentFilterView.swift b/PadelClub/Views/Shared/TournamentFilterView.swift index d9bcb65..e72377c 100644 --- a/PadelClub/Views/Shared/TournamentFilterView.swift +++ b/PadelClub/Views/Shared/TournamentFilterView.swift @@ -9,12 +9,13 @@ import SwiftUI struct TournamentFilterView: View { @EnvironmentObject var dataStore: DataStore + @Environment(NavigationViewModel.self) private var navigation @Environment(\.dismiss) private var dismiss @State private var levels: Set @State private var categories: Set @State private var ageCategories: Set @State private var selectedClubs: Set - var federalDataViewModel: FederalDataViewModel + @State private var federalDataViewModel: FederalDataViewModel init(federalDataViewModel: FederalDataViewModel) { self.federalDataViewModel = federalDataViewModel @@ -27,30 +28,6 @@ struct TournamentFilterView: View { var body: some View { NavigationView { Form { - let clubs : [Club] = dataStore.user.clubsObjects() - if clubs.filter({ $0.code != nil }).isEmpty == false { - Section { - ForEach(clubs.filter({ $0.code != nil })) { club in - LabeledContent { - Button { - if selectedClubs.contains(club.code!) { - selectedClubs.remove(club.code!) - } else { - selectedClubs.insert(club.code!) - } - } label: { - if selectedClubs.contains(club.code!) { - Image(systemName: "checkmark.circle.fill") - } - } - } label: { - Text(club.clubTitle()) - } - } - } header: { - Text("Clubs") - } - } Section { ForEach(TournamentLevel.allCases) { level in LabeledContent { @@ -116,6 +93,58 @@ struct TournamentFilterView: View { } header: { Text("Catégories d'âge") } + + if navigation.agendaDestination == .around { + let clubs : [FederalClub] = federalDataViewModel.searchedClubs + if clubs.isEmpty == false { + Section { + ForEach(clubs) { club in + LabeledContent { + Button { + if selectedClubs.contains(club.federalClubCode) { + selectedClubs.remove(club.federalClubCode) + } else { + selectedClubs.insert(club.federalClubCode) + } + } label: { + if selectedClubs.contains(club.federalClubCode) { + Image(systemName: "checkmark.circle.fill") + } + } + } label: { + Text(club.federalClubName) + } + } + } header: { + Text("Clubs") + } + } + } else { + let clubs : [Club] = dataStore.user.clubsObjects().filter({ $0.code != nil }) + if clubs.isEmpty == false { + Section { + ForEach(clubs) { club in + LabeledContent { + Button { + if selectedClubs.contains(club.code!) { + selectedClubs.remove(club.code!) + } else { + selectedClubs.insert(club.code!) + } + } label: { + if selectedClubs.contains(club.code!) { + Image(systemName: "checkmark.circle.fill") + } + } + } label: { + Text(club.clubTitle()) + } + } + } header: { + Text("Clubs") + } + } + } } .toolbar { ToolbarItem(placement: .topBarLeading) {