Compare commits

..

5 Commits

Author SHA1 Message Date
94202990bb added search
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-31 12:33:11 -06:00
0f9515b90b refactored out passing service mode
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 14:12:07 -06:00
0f4cd465b1 fixed a few bugs for refreshControl and footerView
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 14:02:32 -06:00
52d02ea3a1 now using diffable datasource
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

# Conflicts:
#	EmployeeDirectory/ViewControllers/EmployeesViewController.swift

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 13:59:17 -06:00
28bbade08f fixed bug
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 12:43:07 -06:00
7 changed files with 171 additions and 38 deletions

View File

@ -301,7 +301,8 @@
INFOPLIST_FILE = EmployeeDirectory/Info.plist; INFOPLIST_FILE = EmployeeDirectory/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -327,7 +328,8 @@
INFOPLIST_FILE = EmployeeDirectory/Info.plist; INFOPLIST_FILE = EmployeeDirectory/Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

@ -27,7 +27,7 @@ public enum EmployeeType: String, Codable, CustomStringConvertible {
/// Employee Object /// Employee Object
/// JSON Object defintion /// JSON Object defintion
/// - https://square.github.io/microsite/mobile-interview-project/ /// - https://square.github.io/microsite/mobile-interview-project/
public struct Employee: Codable { public struct Employee: Hashable, Codable {
/// The unique identifier for the employee. Represented as a UUID. /// The unique identifier for the employee. Represented as a UUID.
public let uuid: UUID public let uuid: UUID

View File

@ -10,7 +10,6 @@
public protocol EmployeeServiceProtocol { public protocol EmployeeServiceProtocol {
/// This will get a list of all employees /// This will get a list of all employees
/// - Parameter serviceMode: Mode in which to hit.
/// - Returns: An Employees struct /// - Returns: An Employees struct
func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees func getEmployees() async throws -> Employees
} }

View File

@ -7,11 +7,22 @@
import Foundation import Foundation
/// These are the testing URL Endpoints for different states /// These are the testing URL Endpoints for different states
public enum EmployeeServiceMode: String, CaseIterable { internal enum EmployeeServiceMode: String, CaseIterable {
case production case production
case malformed case malformed
case empty case empty
public var service: EmployeeServiceProtocol {
switch self {
case .production:
return EmployeeService.shared
case .malformed:
return EmployeeMalformedService.shared
case .empty:
return EmployeeEmptyService.shared
}
}
/// Enpoint in which to grabe employees from. /// Enpoint in which to grabe employees from.
public var endpoint: String { public var endpoint: String {
switch self { switch self {
@ -39,7 +50,45 @@ public class EmployeeService: EmployeeServiceProtocol {
/// This will get a list of all employees /// This will get a list of all employees
/// - Parameter serviceMode: Mode in which to hit. /// - Parameter serviceMode: Mode in which to hit.
/// - Returns: An Employees struct /// - Returns: An Employees struct
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees { public func getEmployees() async throws -> Employees {
return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self) return try await NetworkService.shared.fetchData(from: EmployeeServiceMode.production.endpoint, as: Employees.self)
}
}
/// Service Layer for Employees
public class EmployeeMalformedService: EmployeeServiceProtocol {
// MARK: - Properties
public static let shared = EmployeeMalformedService() // Default shared instance
// MARK: - Initializer
public init() {}
// MARK: - Public Methods
/// This will get a list of all employees
/// - Parameter serviceMode: Mode in which to hit.
/// - Returns: An Employees struct
public func getEmployees() async throws -> Employees {
return try await NetworkService.shared.fetchData(from: EmployeeServiceMode.malformed.endpoint, as: Employees.self)
}
}
/// Service Layer for Employees
public class EmployeeEmptyService: EmployeeServiceProtocol {
// MARK: - Properties
public static let shared = EmployeeEmptyService() // Default shared instance
// MARK: - Initializer
public init() {}
// MARK: - Public Methods
/// This will get a list of all employees
/// - Parameter serviceMode: Mode in which to hit.
/// - Returns: An Employees struct
public func getEmployees() async throws -> Employees {
return try await NetworkService.shared.fetchData(from: EmployeeServiceMode.empty.endpoint, as: Employees.self)
} }
} }

View File

@ -16,6 +16,9 @@ class EmployeesViewController: UIViewController {
/// List for the employees /// List for the employees
private let tableView = UITableView() private let tableView = UITableView()
/// Filtering the employees
private let searchBar = UISearchBar()
/// Will only show when fetching data occurs /// Will only show when fetching data occurs
private let activityIndicator = UIActivityIndicatorView(style: .large) private let activityIndicator = UIActivityIndicatorView(style: .large)
@ -31,27 +34,78 @@ class EmployeesViewController: UIViewController {
/// Will show specific state of the viewModel /// Will show specific state of the viewModel
private var footerView: TableFooterView? private var footerView: TableFooterView?
private var dataSource: UITableViewDiffableDataSource<Int, Employee>!
// MARK: - Public Methods // MARK: - Public Methods
public override func viewDidLoad() { public override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setupUI() setupUI()
setupDataSource()
bindViewModel() bindViewModel()
viewModel.fetchEmployees() viewModel.fetchEmployees()
} }
// MARK: - Private Methods // MARK: - Private Methods
/// Setup 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 /// Setup the UI by adding the views to the main view
private func setupUI() { private func setupUI() {
view.backgroundColor = .white view.backgroundColor = .white
// Configure TableView // Configure TableView
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier) tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
tableView.dataSource = self tableView.estimatedRowHeight = 80 // Provide a reasonable default height
view.addSubview(tableView) tableView.rowHeight = UITableView.automaticDimension // Enable dynamic row height
tableView.frame = view.bounds
// 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 //add pull to refresh
tableView.refreshControl = UIRefreshControl() tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged) tableView.refreshControl?.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged)
@ -60,20 +114,19 @@ class EmployeesViewController: UIViewController {
activityIndicator.center = view.center activityIndicator.center = view.center
view.addSubview(activityIndicator) view.addSubview(activityIndicator)
// Configure Mode Selector /// setup Search
modeSegmentedControl.selectedSegmentIndex = 0 searchBar.placeholder = "Search employees"
modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged) searchBar.delegate = self
navigationItem.titleView = modeSegmentedControl navigationItem.titleView = searchBar
} }
/// Using the ViewModel setup combine handlers /// Using the ViewModel setup combine handlers
private func bindViewModel() { private func bindViewModel() {
viewModel.$employees viewModel.$employees
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.sink { [weak self] _ in .sink { [weak self] employees in
self?.updateFooter() self?.updateFooter()
self?.tableView.reloadData() self?.applySnapshot(employees: employees)
self?.tableView.refreshControl?.endRefreshing()
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -84,6 +137,8 @@ class EmployeesViewController: UIViewController {
self?.activityIndicator.startAnimating() self?.activityIndicator.startAnimating()
} else { } else {
self?.activityIndicator.stopAnimating() self?.activityIndicator.stopAnimating()
self?.tableView.refreshControl?.endRefreshing()
self?.updateFooter()
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -100,7 +155,7 @@ class EmployeesViewController: UIViewController {
private func updateFooter() { private func updateFooter() {
var message: String? { var message: String? {
guard !viewModel.isLoading else { return nil } 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 { if let message, !viewModel.isLoading {
@ -136,22 +191,18 @@ extension EmployeesViewController {
case 2: selectedMode = .empty case 2: selectedMode = .empty
default: return default: return
} }
viewModel.changeMode(to: selectedMode) viewModel.changeService(to: selectedMode.service)
} }
} }
/// Mark: - UITableViewDataSource extension EmployeesViewController: UISearchBarDelegate {
extension EmployeesViewController: UITableViewDataSource { public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { viewModel.searchText = searchText // Updates search text in ViewModel
return viewModel.employees.count
} }
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmployeeTableViewCell.identifier, for: indexPath) as? EmployeeTableViewCell else { searchBar.text = ""
return UITableViewCell() searchBar.resignFirstResponder()
} viewModel.searchText = "" // Clear search and reset list
let employee = viewModel.employees[indexPath.row]
cell.configure(with: EmployeeCellViewModel(employee: employee))
return cell
} }
} }

View File

@ -6,18 +6,34 @@
// //
import Foundation import Foundation
import Combine
/// ViewModel that will be bound to an Employees model and used /// ViewModel that will be bound to an Employees model and used
/// specifically with the EmployeesViewController. /// specifically with the EmployeesViewController.
@MainActor @MainActor
public class EmployeesViewModel: ObservableObject { public class EmployeesViewModel: ObservableObject {
private var serviceMode: EmployeeServiceMode = .production private var employeeService: EmployeeServiceProtocol = EmployeeService()
private var allEmployees: [Employee] = [] // Holds unfiltered employees
@Published public private(set) var employees: [Employee] = [] @Published public private(set) var employees: [Employee] = []
@Published public private(set) var errorMessage: String? = nil @Published public private(set) var errorMessage: String? = nil
@Published public private(set) var isLoading: Bool = false @Published public private(set) var isLoading: Bool = false
@Published public var searchText: String = "" // User input from search bar
private var cancellables = Set<AnyCancellable>()
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() { public func fetchEmployees() {
// resetting values out the values before fetching new data // resetting values out the values before fetching new data
@ -27,10 +43,11 @@ public class EmployeesViewModel: ObservableObject {
Task { Task {
do { do {
// Fetch employees using the async method // Fetch employees using the async method
let wrapper = try await EmployeeService.shared.getEmployees(serviceMode) let wrapper = try await employeeService.getEmployees()
// Update published properties // Update published properties
employees = wrapper.employees allEmployees = wrapper.employees
filterEmployees(by: searchText)
isLoading = false isLoading = false
} catch { } catch {
@ -43,9 +60,23 @@ public class EmployeesViewModel: ObservableObject {
} }
public func changeMode(to mode: EmployeeServiceMode) { public func changeService(to employeeService: EmployeeServiceProtocol) {
serviceMode = mode self.employeeService = employeeService
fetchEmployees() 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())
}
}
}
} }

View File

@ -128,6 +128,7 @@ public class EmployeeTableViewCell: UITableViewCell {
// Bind the image to the photoImageView // Bind the image to the photoImageView
smallPhotoSubscriber = viewModel.$smallPhoto smallPhotoSubscriber = viewModel.$smallPhoto
.compactMap { $0 }
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] image in .sink { [weak self] image in
self?.photoImageView.image = image self?.photoImageView.image = image