// // ViewController.swift // EmployeeDirectory // // Created by Matt Bruce on 1/20/25. // import UIKit import Combine class EmployeesViewController: UIViewController { // MARK: - Properties /// List for the employees private let tableView = UITableView() /// Will only show when fetching data occurs private let activityIndicator = UIActivityIndicatorView(style: .large) /// Allows the user to pick between service modes private let modeSegmentedControl = UISegmentedControl(items: EmployeeServiceMode.allCases.map{ $0.rawValue } ) /// ViewModel in which drives the screen private let viewModel = EmployeesViewModel() /// Holds onto the ViewModels Subscribers private var cancellables = Set() /// Will show specific state of the viewModel private var footerView: TableFooterView? // Prevents multiple calls during rapid scrolling private var isFetchingNextPage: Bool = false private var dataSource: UITableViewDiffableDataSource! // MARK: - Public Methods public override func viewDidLoad() { super.viewDidLoad() setupUI() setupDataSource() bindViewModel() viewModel.fetchEmployees() } // MARK: - Private Methods /// Setting up the TableView Datasource private func setupDataSource() { dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, employee in guard let cell = tableView.dequeueReusableCell(withIdentifier: EmployeeTableViewCell.identifier,for: indexPath) as? EmployeeTableViewCell else { return UITableViewCell() } cell.configure(with: EmployeeCellViewModel(employee: employee)) return cell } } /// Snapshot Handling private func applySnapshot(employees: [Employee]) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([0]) snapshot.appendItems(employees, toSection: 0) dataSource.apply(snapshot, animatingDifferences: true) } /// Setup the UI by adding the views to the main view private func setupUI() { view.backgroundColor = .white // Configure TableView tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier) tableView.delegate = self view.addSubview(tableView) tableView.frame = view.bounds //add pull to refresh tableView.refreshControl = UIRefreshControl() tableView.refreshControl?.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged) // Configure Activity Indicator activityIndicator.center = view.center view.addSubview(activityIndicator) // Configure Mode Selector modeSegmentedControl.selectedSegmentIndex = 0 modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged) navigationItem.titleView = modeSegmentedControl let sortButton = UIBarButtonItem(title: "Sort", style: .plain, target: self, action: #selector(showSortOptions)) navigationItem.rightBarButtonItem = sortButton } private func presentSortingActionSheet() { let alert = UIAlertController(title: "Sort Employees", message: "Select a sort option", preferredStyle: .actionSheet) alert.addAction(UIAlertAction(title: "Full Name (Asc)", style: .default) { [weak self] _ in self?.viewModel.sortField = .fullName self?.viewModel.sortOrder = .ascending }) alert.addAction(UIAlertAction(title: "Full Name (Desc)", style: .default) { [weak self] _ in self?.viewModel.sortField = .fullName self?.viewModel.sortOrder = .descending }) alert.addAction(UIAlertAction(title: "Team (Asc)", style: .default) { [weak self] _ in self?.viewModel.sortField = .team self?.viewModel.sortOrder = .ascending }) alert.addAction(UIAlertAction(title: "Team (Desc)", style: .default) { [weak self] _ in self?.viewModel.sortField = .team self?.viewModel.sortOrder = .descending }) alert.addAction(UIAlertAction(title: "Employee Type (Asc)", style: .default) { [weak self] _ in self?.viewModel.sortField = .employeeType self?.viewModel.sortOrder = .ascending }) alert.addAction(UIAlertAction(title: "Employee Type (Desc)", style: .default) { [weak self] _ in self?.viewModel.sortField = .employeeType self?.viewModel.sortOrder = .ascending }) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) present(alert, animated: true, completion: nil) } /// Using the ViewModel setup combine handlers private func bindViewModel() { viewModel.$employees .receive(on: RunLoop.main) .sink { [weak self] employees in self?.applySnapshot(employees: employees) } .store(in: &cancellables) viewModel.$isLoading .receive(on: RunLoop.main) .sink { [weak self] isLoading in guard let self, let refreshControl = self.tableView.refreshControl else { return } if isLoading { activityIndicator.startAnimating() } else { activityIndicator.stopAnimating() refreshControl.endRefreshing() updateFooter() } } .store(in: &cancellables) viewModel.$errorMessage .receive(on: RunLoop.main) .sink { [weak self] _ in self?.updateFooter() } .store(in: &cancellables) } private func animateEmployeeChanges(from oldEmployees: [Employee], to newEmployees: [Employee]) { let oldCount = oldEmployees.count let newCount = newEmployees.count // Case: Removing all employees if oldCount > 0 && newCount == 0 { let indexPaths = (0.. 0 { tableView.reloadData() return } } /// Show state in specific use-cases for the EmployeesViewModel private func updateFooter() { var footerMessage: String? // Check for error messages or empty state first if let message = viewModel.errorMessage ?? (viewModel.employees.isEmpty && viewModel.isLoading == false ? "No employees found, please try to refresh." : nil) { footerMessage = message } // Show loading footer if there are more pages to load else if (viewModel.isLoading || isFetchingNextPage) && viewModel.hasMorePages { footerMessage = "Loading more employees..." } if let footerMessage { // Lazy initialize footerView if needed if footerView == nil { footerView = TableFooterView(message: footerMessage) } else { footerView?.update(message: footerMessage) } footerView?.frame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: 150) tableView.tableFooterView = footerView } else { tableView.tableFooterView = nil } } } // MARK: - UITableViewDelegate extension EmployeesViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { let offsetY = scrollView.contentOffset.y let contentHeight = scrollView.contentSize.height let scrollViewHeight = scrollView.frame.size.height if offsetY > contentHeight - scrollViewHeight - 100 && !isFetchingNextPage { isFetchingNextPage = true updateFooter() viewModel.loadNextPage() // Reset the flag after a short delay to allow new fetches DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.isFetchingNextPage = false } } } } // Mark: - Objective-C Methods extension EmployeesViewController { /// Show sort options @objc private func showSortOptions() { presentSortingActionSheet() } /// Fetch the Employees @objc private func didPullToRefresh() { viewModel.fetchEmployees() } /// This will handle services changes to test conditions to ensure UI works correctly. /// - Parameter sender: Mode in which to test @objc private func onServiceModeChange(_ sender: UISegmentedControl) { let selectedMode: EmployeeServiceMode switch sender.selectedSegmentIndex { case 0: selectedMode = .production case 1: selectedMode = .malformed case 2: selectedMode = .empty default: return } viewModel.changeService(to: selectedMode.service) } }