block-employee-directory/EmployeeDirectory/ViewControllers/EmployeesViewController.swift
Matt Bruce 146c90f2ee updating comments
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 13:33:35 -06:00

256 lines
9.1 KiB
Swift

//
// 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<AnyCancellable>()
/// 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<Int, Employee>!
// 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<Int, Employee>(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<Int, Employee>()
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
if isLoading {
self?.activityIndicator.startAnimating()
} else {
self?.activityIndicator.stopAnimating()
self?.tableView.refreshControl?.endRefreshing() // End refresh control
}
}
.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..<oldCount).map { IndexPath(row: $0, section: 0) }
tableView.performBatchUpdates {
tableView.deleteRows(at: indexPaths, with: .fade)
}
return
}
// Case: Resetting with new data (or switching modes)
if newCount > 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 ? "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.changeMode(to: selectedMode)
}
}