From 62fd9c561065a73a6c614bcc77a2d77f121963b1 Mon Sep 17 00:00:00 2001 From: Laurent Date: Tue, 3 Dec 2024 20:35:06 +0100 Subject: [PATCH] Adds a way to share a tournament with others --- PadelClub.xcodeproj/project.pbxproj | 8 + PadelClub/AppDelegate.swift | 2 +- PadelClub/ViewModel/Screen.swift | 1 + PadelClub/Views/Components/Labels.swift | 6 + .../Navigation/Agenda/EventListView.swift | 17 ++ .../Views/Tournament/TournamentView.swift | 12 ++ PadelClub/Views/User/UserSearchView.swift | 168 ++++++++++++++++++ 7 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 PadelClub/Views/User/UserSearchView.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 1531660..74a898c 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ C425D4122B6D249E002A7B48 /* PadelClubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D4112B6D249E002A7B48 /* PadelClubTests.swift */; }; C425D41C2B6D249E002A7B48 /* PadelClubUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D41B2B6D249E002A7B48 /* PadelClubUITests.swift */; }; C425D41E2B6D249E002A7B48 /* PadelClubUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C425D41D2B6D249E002A7B48 /* PadelClubUITestsLaunchTests.swift */; }; + C4339BFB2CFF7D68004E5F09 /* UserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4339BFA2CFF7D64004E5F09 /* UserSearchView.swift */; }; + C4339BFC2CFF7D68004E5F09 /* UserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4339BFA2CFF7D64004E5F09 /* UserSearchView.swift */; }; + C4339BFD2CFF7D68004E5F09 /* UserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4339BFA2CFF7D64004E5F09 /* UserSearchView.swift */; }; C4489BE22C05BF5000043F3D /* DebugSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4489BE12C05BF5000043F3D /* DebugSettingsView.swift */; }; C44B79112BBDA63A00906534 /* Locale+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44B79102BBDA63A00906534 /* Locale+Extensions.swift */; }; C45BAE3B2BC6DF10002EEC8A /* SyncedProducts.storekit in Resources */ = {isa = PBXBuildFile; fileRef = C45BAE3A2BC6DF10002EEC8A /* SyncedProducts.storekit */; }; @@ -972,6 +975,7 @@ C425D4172B6D249E002A7B48 /* PadelClubUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PadelClubUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C425D41B2B6D249E002A7B48 /* PadelClubUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadelClubUITests.swift; sourceTree = ""; }; C425D41D2B6D249E002A7B48 /* PadelClubUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PadelClubUITestsLaunchTests.swift; sourceTree = ""; }; + C4339BFA2CFF7D64004E5F09 /* UserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSearchView.swift; sourceTree = ""; }; C4489BE12C05BF5000043F3D /* DebugSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugSettingsView.swift; sourceTree = ""; }; C44B79102BBDA63A00906534 /* Locale+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Locale+Extensions.swift"; sourceTree = ""; }; C45BAE3A2BC6DF10002EEC8A /* SyncedProducts.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = SyncedProducts.storekit; sourceTree = ""; }; @@ -1543,6 +1547,7 @@ C4A47D852B7BA33F00ADC637 /* User */ = { isa = PBXGroup; children = ( + C4339BFA2CFF7D64004E5F09 /* UserSearchView.swift */, C4A47DB22B86387500ADC637 /* AccountView.swift */, C4A47DA82B85F82100ADC637 /* ChangePasswordView.swift */, C4A47DA52B83948E00ADC637 /* LoginView.swift */, @@ -2452,6 +2457,7 @@ C4A47D9F2B7D0BCE00ADC637 /* StepperView.swift in Sources */, FFC83D4F2BB807D100750834 /* RoundsView.swift in Sources */, FF1CBC1B2BB53D1F0036DAAB /* FederalTournament.swift in Sources */, + C4339BFB2CFF7D68004E5F09 /* UserSearchView.swift in Sources */, FF8F26412BADFC8700650388 /* TournamentInitView.swift in Sources */, C4A47D8A2B7BBB6500ADC637 /* SubscriptionView.swift in Sources */, FFD655D82C8DE27400E5B35E /* TournamentLookUpView.swift in Sources */, @@ -2830,6 +2836,7 @@ FF4CBF992C996C0600151637 /* StoreManager.swift in Sources */, FF4CBF9A2C996C0600151637 /* SearchViewModel.swift in Sources */, FF4CBF9B2C996C0600151637 /* PlayerRegistration.swift in Sources */, + C4339BFD2CFF7D68004E5F09 /* UserSearchView.swift in Sources */, FF4CBF9C2C996C0600151637 /* ImportedPlayerView.swift in Sources */, FF4CBF9D2C996C0600151637 /* EditingTeamView.swift in Sources */, FF4CBF9E2C996C0600151637 /* NetworkManagerError.swift in Sources */, @@ -3096,6 +3103,7 @@ FF70FB182C90584900129CC2 /* StoreManager.swift in Sources */, FF70FB192C90584900129CC2 /* SearchViewModel.swift in Sources */, FF70FB1A2C90584900129CC2 /* PlayerRegistration.swift in Sources */, + C4339BFC2CFF7D68004E5F09 /* UserSearchView.swift in Sources */, FF70FB1B2C90584900129CC2 /* ImportedPlayerView.swift in Sources */, FF70FB1C2C90584900129CC2 /* EditingTeamView.swift in Sources */, FF70FB1D2C90584900129CC2 /* NetworkManagerError.swift in Sources */, diff --git a/PadelClub/AppDelegate.swift b/PadelClub/AppDelegate.swift index 12a9138..14e6dcb 100644 --- a/PadelClub/AppDelegate.swift +++ b/PadelClub/AppDelegate.swift @@ -19,7 +19,7 @@ class AppDelegate : NSObject, UIApplicationDelegate, UNUserNotificationCenterDel UIApplication.shared.registerForRemoteNotifications() UNUserNotificationCenter.current().delegate = self - + return true } diff --git a/PadelClub/ViewModel/Screen.swift b/PadelClub/ViewModel/Screen.swift index cb4d0d6..9ac761e 100644 --- a/PadelClub/ViewModel/Screen.swift +++ b/PadelClub/ViewModel/Screen.swift @@ -20,4 +20,5 @@ enum Screen: String, Codable { case broadcast case event case print + case share } diff --git a/PadelClub/Views/Components/Labels.swift b/PadelClub/Views/Components/Labels.swift index 7049334..a87e34a 100644 --- a/PadelClub/Views/Components/Labels.swift +++ b/PadelClub/Views/Components/Labels.swift @@ -31,6 +31,12 @@ struct LabelDelete: View { } } +struct ShareLabel: View { + var body: some View { + Label("Partager", systemImage: "square.and.arrow.up.fill") + } +} + struct LabelFilter: View { var body: some View { Label("Filtrer", systemImage: "line.3.horizontal.decrease.circle") diff --git a/PadelClub/Views/Navigation/Agenda/EventListView.swift b/PadelClub/Views/Navigation/Agenda/EventListView.swift index 04d9741..eb848e7 100644 --- a/PadelClub/Views/Navigation/Agenda/EventListView.swift +++ b/PadelClub/Views/Navigation/Agenda/EventListView.swift @@ -16,6 +16,8 @@ struct EventListView: View { let tournaments: [FederalTournamentHolder] let sortAscending: Bool + + @State var showUserSearch: Bool = false var body: some View { let groupedTournamentsByDate = Dictionary(grouping: federalDataViewModel.filteredFederalTournaments(from: tournaments)) { $0.startDate.startOfMonth } @@ -118,6 +120,15 @@ struct EventListView: View { private func _tournamentView(_ tournament: Tournament) -> some View { NavigationLink(value: tournament) { TournamentCellView(tournament: tournament) + .popover(isPresented: self.$showUserSearch) { + UserSearchView { user in + do { + try StoreCenter.main.giveUserAccess(user.id, data: tournament) + } catch { + Logger.error(error) + } + } + } } .contextMenu { if tournament.hasEnded() == false { @@ -144,6 +155,12 @@ struct EventListView: View { } label: { LabelDelete() } + Button() { + self.showUserSearch = true + } label: { + ShareLabel().tint(.orange) + } + } #endif } diff --git a/PadelClub/Views/Tournament/TournamentView.swift b/PadelClub/Views/Tournament/TournamentView.swift index c5645c6..f3c9992 100644 --- a/PadelClub/Views/Tournament/TournamentView.swift +++ b/PadelClub/Views/Tournament/TournamentView.swift @@ -111,6 +111,14 @@ struct TournamentView: View { } case .print: PrintSettingsView(tournament: tournament) + case .share: + UserSearchView { user in + do { + try StoreCenter.main.giveUserAccess(user.id, data: tournament) + } catch { + Logger.error(error) + } + } } } .environment(tournament) @@ -192,6 +200,10 @@ struct TournamentView: View { NavigationLink(value: Screen.print) { Label("Imprimer", systemImage: "printer") } + + NavigationLink(value: Screen.share) { + Label("Partager", systemImage: "square.and.arrow.up") + } Divider() diff --git a/PadelClub/Views/User/UserSearchView.swift b/PadelClub/Views/User/UserSearchView.swift new file mode 100644 index 0000000..809a5d0 --- /dev/null +++ b/PadelClub/Views/User/UserSearchView.swift @@ -0,0 +1,168 @@ +// +// UserSearchView.swift +// PadelClub +// +// Created by Laurent Morvillier on 03/12/2024. +// + +import Combine +import LeStorage +import SwiftUI + +class UserSearchViewModel: ObservableObject { + @Published var searchText = "" + @Published var users: [ShortUser] = [] + @Published var isLoading = false + @Published var error: String? + @Published var selectedUser: ShortUser? = nil + + private var cancellables = Set() + private var originalUsers: [ShortUser] = [] + private var lastSearchTerm = "" + + init() { + // Debounce search to avoid too many requests + $searchText + .removeDuplicates() + .debounce(for: .milliseconds(300), scheduler: RunLoop.main) + .sink { [weak self] searchTerm in + self?.handleSearch(searchTerm) + } + .store(in: &cancellables) + } + + private func handleSearch(_ searchTerm: String) { + guard !searchTerm.isEmpty else { + users = [] + return + } + + // If going backwards in search, filter existing results + if searchTerm.count < lastSearchTerm.count && !originalUsers.isEmpty { + filterExistingResults(searchTerm) + return + } + + // Otherwise, make a new request + performServerSearch(searchTerm) + } + + private func filterExistingResults(_ searchTerm: String) { + users = originalUsers.filter { user in + user.firstName.localizedCaseInsensitiveContains(searchTerm) + || user.lastName.localizedCaseInsensitiveContains(searchTerm) + } + } + + private func performServerSearch(_ searchTerm: String) { + isLoading = true + error = nil + + Task { + do { + let services = try StoreCenter.main.service() + let searchResults = try await services.searchUsers(string: searchTerm) + + await MainActor.run { + self.originalUsers = searchResults + self.users = searchResults + self.lastSearchTerm = searchTerm + self.isLoading = false + } + } catch { + await MainActor.run { + self.error = error.localizedDescription + self.isLoading = false + } + } + } + } +} + +struct UserSearchView: View { + @StateObject private var viewModel = UserSearchViewModel() + + var handler: (ShortUser) -> Void + + var body: some View { + NavigationView { + VStack { + searchField + + if viewModel.isLoading { + loadingView + } else if let error = viewModel.error { + errorView(error) + } else { + List { + ForEach(viewModel.users, id: \.id) { user in + let isSelected = (user.id == viewModel.selectedUser?.id) + UserRow(user: user, isSelected: isSelected) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.selectedUser = user + } + } + } + .listStyle(PlainListStyle()) + } + } + .navigationTitle("Search Users") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Select") { + if let selectedUser = viewModel.selectedUser { + handler(selectedUser) + } + } + .disabled(viewModel.selectedUser == nil) + } + } + } + } + + private var searchField: some View { + TextField("Search users...", text: $viewModel.searchText) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + } + + private var loadingView: some View { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.5) + .frame(maxHeight: .infinity) + } + + private func errorView(_ error: String) -> some View { + Text(error) + .foregroundColor(.red) + .frame(maxHeight: .infinity) + } + +} + +struct UserRow: View { + let user: ShortUser + let isSelected: Bool + + var body: some View { + HStack { + Text("\(user.firstName) \(user.lastName)") + Spacer() + if self.isSelected { + Image(systemName: "checkmark").tint(.logoOrange) + } + } + .padding(.vertical, 4) + } +} + +// Preview provider +struct UserSearchView_Previews: PreviewProvider { + static var previews: some View { + UserSearchView { user in + + } + } +}