Compare commits
No commits in common. "search" and "develop" have entirely different histories.
@ -301,8 +301,7 @@
|
|||||||
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 = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -328,8 +327,7 @@
|
|||||||
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 = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@ -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: Hashable, Codable {
|
public struct Employee: 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
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
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() async throws -> Employees
|
func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,22 +7,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// These are the testing URL Endpoints for different states
|
/// These are the testing URL Endpoints for different states
|
||||||
internal enum EmployeeServiceMode: String, CaseIterable {
|
public 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 {
|
||||||
@ -50,45 +39,7 @@ 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() async throws -> Employees {
|
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees {
|
||||||
return try await NetworkService.shared.fetchData(from: EmployeeServiceMode.production.endpoint, as: Employees.self)
|
return try await NetworkService.shared.fetchData(from: serviceMode.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,9 +16,6 @@ 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)
|
||||||
|
|
||||||
@ -34,77 +31,26 @@ 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.estimatedRowHeight = 80 // Provide a reasonable default height
|
tableView.dataSource = self
|
||||||
tableView.rowHeight = UITableView.automaticDimension // Enable dynamic row height
|
view.addSubview(tableView)
|
||||||
|
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()
|
||||||
@ -114,19 +60,20 @@ class EmployeesViewController: UIViewController {
|
|||||||
activityIndicator.center = view.center
|
activityIndicator.center = view.center
|
||||||
view.addSubview(activityIndicator)
|
view.addSubview(activityIndicator)
|
||||||
|
|
||||||
/// setup Search
|
// Configure Mode Selector
|
||||||
searchBar.placeholder = "Search employees"
|
modeSegmentedControl.selectedSegmentIndex = 0
|
||||||
searchBar.delegate = self
|
modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged)
|
||||||
navigationItem.titleView = searchBar
|
navigationItem.titleView = modeSegmentedControl
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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] employees in
|
.sink { [weak self] _ in
|
||||||
self?.updateFooter()
|
self?.updateFooter()
|
||||||
self?.applySnapshot(employees: employees)
|
self?.tableView.reloadData()
|
||||||
|
self?.tableView.refreshControl?.endRefreshing()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
@ -137,8 +84,6 @@ 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)
|
||||||
@ -155,7 +100,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" : nil)
|
return viewModel.errorMessage ?? (viewModel.employees.isEmpty ? "No employees found, please try to refresh." : nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let message, !viewModel.isLoading {
|
if let message, !viewModel.isLoading {
|
||||||
@ -191,18 +136,22 @@ extension EmployeesViewController {
|
|||||||
case 2: selectedMode = .empty
|
case 2: selectedMode = .empty
|
||||||
default: return
|
default: return
|
||||||
}
|
}
|
||||||
viewModel.changeService(to: selectedMode.service)
|
viewModel.changeMode(to: selectedMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EmployeesViewController: UISearchBarDelegate {
|
/// Mark: - UITableViewDataSource
|
||||||
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
extension EmployeesViewController: UITableViewDataSource {
|
||||||
viewModel.searchText = searchText // Updates search text in ViewModel
|
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
|
return viewModel.employees.count
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
searchBar.text = ""
|
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmployeeTableViewCell.identifier, for: indexPath) as? EmployeeTableViewCell else {
|
||||||
searchBar.resignFirstResponder()
|
return UITableViewCell()
|
||||||
viewModel.searchText = "" // Clear search and reset list
|
}
|
||||||
|
let employee = viewModel.employees[indexPath.row]
|
||||||
|
cell.configure(with: EmployeeCellViewModel(employee: employee))
|
||||||
|
return cell
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,34 +6,18 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
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 employeeService: EmployeeServiceProtocol = EmployeeService()
|
private var serviceMode: EmployeeServiceMode = .production
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sets up Combine to filter employees as the search text changes
|
public init() {}
|
||||||
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
|
||||||
@ -43,11 +27,10 @@ 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.getEmployees()
|
let wrapper = try await EmployeeService.shared.getEmployees(serviceMode)
|
||||||
|
|
||||||
// Update published properties
|
// Update published properties
|
||||||
allEmployees = wrapper.employees
|
employees = wrapper.employees
|
||||||
filterEmployees(by: searchText)
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
@ -60,23 +43,9 @@ public class EmployeesViewModel: ObservableObject {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func changeService(to employeeService: EmployeeServiceProtocol) {
|
public func changeMode(to mode: EmployeeServiceMode) {
|
||||||
self.employeeService = employeeService
|
serviceMode = mode
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -128,7 +128,6 @@ 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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user