From 94202990bbdd94185d21d3f0fce2da648b2a7905 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 31 Jan 2025 12:33:11 -0600 Subject: [PATCH] added search Signed-off-by: Matt Bruce --- EmployeeDirectory.xcodeproj/project.pbxproj | 6 +- .../EmployeesViewController.swift | 59 ++++++++++++++++--- .../ViewModels/EmployeesViewModel.swift | 35 ++++++++++- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/EmployeeDirectory.xcodeproj/project.pbxproj b/EmployeeDirectory.xcodeproj/project.pbxproj index d1a8b39..17a862b 100644 --- a/EmployeeDirectory.xcodeproj/project.pbxproj +++ b/EmployeeDirectory.xcodeproj/project.pbxproj @@ -301,7 +301,8 @@ INFOPLIST_FILE = EmployeeDirectory/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -327,7 +328,8 @@ INFOPLIST_FILE = EmployeeDirectory/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/EmployeeDirectory/ViewControllers/EmployeesViewController.swift b/EmployeeDirectory/ViewControllers/EmployeesViewController.swift index c1d1914..f706b13 100644 --- a/EmployeeDirectory/ViewControllers/EmployeesViewController.swift +++ b/EmployeeDirectory/ViewControllers/EmployeesViewController.swift @@ -16,6 +16,9 @@ class EmployeesViewController: UIViewController { /// List for the employees private let tableView = UITableView() + /// Filtering the employees + private let searchBar = UISearchBar() + /// Will only show when fetching data occurs private let activityIndicator = UIActivityIndicatorView(style: .large) @@ -70,10 +73,39 @@ class EmployeesViewController: UIViewController { view.backgroundColor = .white // Configure TableView + tableView.translatesAutoresizingMaskIntoConstraints = false tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier) - view.addSubview(tableView) - tableView.frame = view.bounds + tableView.estimatedRowHeight = 80 // Provide a reasonable default height + tableView.rowHeight = UITableView.automaticDimension // Enable dynamic row height + + // Configure Segmented Control + let segmentedContainer = UIView() + segmentedContainer.translatesAutoresizingMaskIntoConstraints = false + segmentedContainer.addSubview(modeSegmentedControl) + + modeSegmentedControl.selectedSegmentIndex = 0 + modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged) + modeSegmentedControl.translatesAutoresizingMaskIntoConstraints = false + let stackView = UIStackView(arrangedSubviews: [segmentedContainer, tableView]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.distribution = .fill + stackView.spacing = 5 + + view.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + modeSegmentedControl.topAnchor.constraint(equalTo: segmentedContainer.topAnchor), + modeSegmentedControl.bottomAnchor.constraint(equalTo: segmentedContainer.bottomAnchor, constant: -5), + modeSegmentedControl.leadingAnchor.constraint(equalTo: segmentedContainer.leadingAnchor, constant: 16), + modeSegmentedControl.trailingAnchor.constraint(equalTo: segmentedContainer.trailingAnchor, constant: -16) + ]) + //add pull to refresh tableView.refreshControl = UIRefreshControl() tableView.refreshControl?.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged) @@ -82,10 +114,10 @@ class EmployeesViewController: UIViewController { activityIndicator.center = view.center view.addSubview(activityIndicator) - // Configure Mode Selector - modeSegmentedControl.selectedSegmentIndex = 0 - modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged) - navigationItem.titleView = modeSegmentedControl + /// setup Search + searchBar.placeholder = "Search employees" + searchBar.delegate = self + navigationItem.titleView = searchBar } /// Using the ViewModel setup combine handlers @@ -93,6 +125,7 @@ class EmployeesViewController: UIViewController { viewModel.$employees .receive(on: RunLoop.main) .sink { [weak self] employees in + self?.updateFooter() self?.applySnapshot(employees: employees) } .store(in: &cancellables) @@ -122,7 +155,7 @@ class EmployeesViewController: UIViewController { private func updateFooter() { var message: String? { guard !viewModel.isLoading else { return nil } - return viewModel.errorMessage ?? (viewModel.employees.isEmpty ? "No employees found, please try to refresh." : nil) + return viewModel.errorMessage ?? (viewModel.employees.isEmpty ? "No employees found" : nil) } if let message, !viewModel.isLoading { @@ -161,3 +194,15 @@ extension EmployeesViewController { viewModel.changeService(to: selectedMode.service) } } + +extension EmployeesViewController: UISearchBarDelegate { + public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + viewModel.searchText = searchText // Updates search text in ViewModel + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.text = "" + searchBar.resignFirstResponder() + viewModel.searchText = "" // Clear search and reset list + } +} diff --git a/EmployeeDirectory/ViewModels/EmployeesViewModel.swift b/EmployeeDirectory/ViewModels/EmployeesViewModel.swift index ab34896..3570611 100644 --- a/EmployeeDirectory/ViewModels/EmployeesViewModel.swift +++ b/EmployeeDirectory/ViewModels/EmployeesViewModel.swift @@ -6,6 +6,7 @@ // import Foundation +import Combine /// ViewModel that will be bound to an Employees model and used /// specifically with the EmployeesViewController. @@ -13,11 +14,26 @@ import Foundation public class EmployeesViewModel: ObservableObject { private var employeeService: EmployeeServiceProtocol = EmployeeService() + private var allEmployees: [Employee] = [] // Holds unfiltered employees @Published public private(set) var employees: [Employee] = [] @Published public private(set) var errorMessage: String? = nil @Published public private(set) var isLoading: Bool = false + @Published public var searchText: String = "" // User input from search bar + private var cancellables = Set() + public init() { + setupSearch() + } - public init() {} + /// Sets up Combine to filter employees as the search text changes + private func setupSearch() { + $searchText + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) // Add delay to avoid frequent filtering + .removeDuplicates() + .sink { [weak self] query in + self?.filterEmployees(by: query) + } + .store(in: &cancellables) + } public func fetchEmployees() { // resetting values out the values before fetching new data @@ -30,7 +46,8 @@ public class EmployeesViewModel: ObservableObject { let wrapper = try await employeeService.getEmployees() // Update published properties - employees = wrapper.employees + allEmployees = wrapper.employees + filterEmployees(by: searchText) isLoading = false } catch { @@ -47,5 +64,19 @@ public class EmployeesViewModel: ObservableObject { self.employeeService = employeeService fetchEmployees() } + + /// Filters employees based on search query + private func filterEmployees(by query: String) { + if query.isEmpty { + employees = allEmployees // Reset to all employees if no search text + } else { + employees = allEmployees.filter { employee in + employee.fullName.lowercased().contains(query.lowercased()) || + employee.team.lowercased().contains(query.lowercased()) || + employee.emailAddress.lowercased().contains(query.lowercased()) + } + } + } + }