Compare commits
29 Commits
develop
...
requestBui
| Author | SHA1 | Date | |
|---|---|---|---|
| b1e2d7944a | |||
| 4e59fa6f6a | |||
| 67b2163134 | |||
| 0f9515b90b | |||
| 0f4cd465b1 | |||
| 52d02ea3a1 | |||
| 146c90f2ee | |||
| 7501ce5936 | |||
| 3238defe84 | |||
| fa5f782968 | |||
| 6280aa42cb | |||
| b9bd3ae310 | |||
| 4f0d099f60 | |||
| 6115b8420b | |||
| 28bbade08f | |||
| bc635714b4 | |||
| da6e68c219 | |||
| 95cbafec57 | |||
| 285b448bce | |||
| 31c4f08303 | |||
| 68ab23aefd | |||
| 7e0c2fd310 | |||
| 427e862b6b | |||
| e5175dd84e | |||
| 4ae060597c | |||
| 801830e5b8 | |||
| 13de040621 | |||
| 3314304b46 | |||
| 9dcd4846ce |
55
EmployeeDirectory/Classes/RequestBuilder.swift
Normal file
55
EmployeeDirectory/Classes/RequestBuilder.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// RequestBuilder.swift
|
||||||
|
// EmployeeDirectory
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/21/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Enum representing HTTP methods
|
||||||
|
public enum HTTPMethod: String {
|
||||||
|
case get = "GET"
|
||||||
|
case post = "POST"
|
||||||
|
case put = "PUT"
|
||||||
|
case delete = "DELETE"
|
||||||
|
case patch = "PATCH"
|
||||||
|
case head = "HEAD"
|
||||||
|
case options = "OPTIONS"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A utility for constructing HTTP requests
|
||||||
|
public struct RequestBuilder {
|
||||||
|
private var url: URL
|
||||||
|
private var method: HTTPMethod
|
||||||
|
private var headers: [String: String] = [:]
|
||||||
|
private var body: Data?
|
||||||
|
|
||||||
|
public init(url: URL, method: HTTPMethod = .get) {
|
||||||
|
self.url = url
|
||||||
|
self.method = method
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a header to the request
|
||||||
|
@discardableResult
|
||||||
|
public mutating func addHeader(key: String, value: String) -> Self {
|
||||||
|
headers[key] = value
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the body of the request
|
||||||
|
@discardableResult
|
||||||
|
public mutating func setBody(_ data: Data?) -> Self {
|
||||||
|
body = data
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `URLRequest`
|
||||||
|
public func build() -> URLRequest {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.allHTTPHeaderFields = headers
|
||||||
|
request.httpBody = body
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -11,8 +11,29 @@ public struct Employees: Codable {
|
|||||||
|
|
||||||
/// Array of Employees
|
/// Array of Employees
|
||||||
public var employees: [Employee]
|
public var employees: [Employee]
|
||||||
|
public let total: Int
|
||||||
|
public let page: Int
|
||||||
|
public let perPage: Int
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case employees
|
case employees
|
||||||
|
case total
|
||||||
|
case page
|
||||||
|
case perPage = "per_page"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(employees: [Employee], total: Int = 0, page: Int = 1, perPage: Int = 10) {
|
||||||
|
self.employees = employees
|
||||||
|
self.total = total
|
||||||
|
self.page = page
|
||||||
|
self.perPage = perPage
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.employees = try container.decode([Employee].self, forKey: .employees)
|
||||||
|
self.total = try container.decodeIfPresent(Int.self, forKey: .total) ?? self.employees.count
|
||||||
|
self.page = try container.decodeIfPresent(Int.self, forKey: .page) ?? 1
|
||||||
|
self.perPage = try container.decodeIfPresent(Int.self, forKey: .perPage) ?? self.total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,12 +5,10 @@
|
|||||||
// Created by Matt Bruce on 1/20/25.
|
// Created by Matt Bruce on 1/20/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|
||||||
/// This will be the interface for the API for Employees
|
/// This will be the interface for the API for Employees
|
||||||
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
|
||||||
|
func getEmployees(page: Int, perPage: Int, sortField: EmployeeSortField, sortOrder: EmployeeSortOrder) async throws -> Employees
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,12 +6,35 @@
|
|||||||
//
|
//
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
public enum EmployeeSortField: String {
|
||||||
|
case fullName
|
||||||
|
case team
|
||||||
|
case employeeType
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EmployeeSortOrder: String {
|
||||||
|
case ascending = "asc"
|
||||||
|
case descending = "desc"
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 MockEmployeeService.shared
|
||||||
|
//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 +62,82 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch employees with pagination support
|
||||||
|
/// - Parameters:
|
||||||
|
/// - page: The page number to fetch.
|
||||||
|
/// - perPage: The number of employees per page.
|
||||||
|
/// - Returns: A paginated Employees object.
|
||||||
|
public func getEmployees(page: Int, perPage: Int,
|
||||||
|
sortField: EmployeeSortField = .fullName,
|
||||||
|
sortOrder: EmployeeSortOrder = .ascending) async throws -> Employees {
|
||||||
|
|
||||||
|
guard var urlComponents = URLComponents(string: EmployeeServiceMode.production.endpoint) else {
|
||||||
|
throw NetworkServiceError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
urlComponents.queryItems = [
|
||||||
|
URLQueryItem(name: "page", value: "\(page)"),
|
||||||
|
URLQueryItem(name: "perPage", value: "\(perPage)"),
|
||||||
|
URLQueryItem(name: "sortField", value: "\(sortField.rawValue)"),
|
||||||
|
URLQueryItem(name: "sortOrder", value: "\(sortOrder.rawValue)")
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let url = urlComponents.url else {
|
||||||
|
throw NetworkServiceError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = RequestBuilder(url: url, method: .get).build()
|
||||||
|
|
||||||
|
return try await NetworkService.shared.fetchData(with: request, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getEmployees(page: Int, perPage: Int, sortField: EmployeeSortField, sortOrder: EmployeeSortOrder) 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getEmployees(page: Int, perPage: Int, sortField: EmployeeSortField, sortOrder: EmployeeSortOrder) async throws -> Employees {
|
||||||
|
return try await NetworkService.shared.fetchData(from: EmployeeServiceMode.empty.endpoint, as: Employees.self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
EmployeeDirectory/Services/MockEmployeeService.swift
Normal file
85
EmployeeDirectory/Services/MockEmployeeService.swift
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
//
|
||||||
|
// MockEmployeeService.swift
|
||||||
|
// EmployeeDirectory
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/21/25.
|
||||||
|
//
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class MockEmployeeService: EmployeeServiceProtocol {
|
||||||
|
// MARK: - Properties
|
||||||
|
public static let shared = MockEmployeeService() // Default shared instance
|
||||||
|
private var wrapper: Employees
|
||||||
|
private var sortField: EmployeeSortField = .fullName
|
||||||
|
private var sortOrder: EmployeeSortOrder = .ascending
|
||||||
|
|
||||||
|
// MARK: - Initializer
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
let jsonFileName = "localTest"
|
||||||
|
guard let url = Bundle.main.url(forResource: jsonFileName, withExtension: "json"),
|
||||||
|
let data = try? Data(contentsOf: url),
|
||||||
|
let localData = try? JSONDecoder().decode(Employees.self, from: data) else {
|
||||||
|
wrapper = .init(employees: [], total: 0, page: 0, perPage: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wrapper = localData
|
||||||
|
sortEmployees()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getEmployees() async throws -> Employees {
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getEmployees(page: Int, perPage: Int,
|
||||||
|
sortField: EmployeeSortField = .fullName,
|
||||||
|
sortOrder: EmployeeSortOrder = .ascending) async throws -> Employees {
|
||||||
|
|
||||||
|
//resort mock data
|
||||||
|
if sortField != self.sortField || sortOrder != self.sortOrder {
|
||||||
|
self.sortField = sortField
|
||||||
|
self.sortOrder = sortOrder
|
||||||
|
sortEmployees()
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalUsers = wrapper.employees.count
|
||||||
|
let startIndex = (page - 1) * perPage
|
||||||
|
let endIndex = min(startIndex + perPage, totalUsers)
|
||||||
|
|
||||||
|
guard startIndex < totalUsers else {
|
||||||
|
return .init(employees: [], total: totalUsers, page: page, perPage: perPage) // Return empty if out of bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
//paged
|
||||||
|
let pagedEmployees = Array(wrapper.employees[startIndex..<endIndex])
|
||||||
|
return .init(employees: pagedEmployees, total: totalUsers, page: page, perPage: perPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortEmployees() {
|
||||||
|
wrapper = .init(employees: wrapper.employees.sorted(by: sortField, with: sortOrder),
|
||||||
|
total: wrapper.employees.count,
|
||||||
|
page: 1,
|
||||||
|
perPage: 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == Employee {
|
||||||
|
func sorted(by field: EmployeeSortField, with option: EmployeeSortOrder) -> [Employee] {
|
||||||
|
switch field {
|
||||||
|
case .fullName:
|
||||||
|
return option == .ascending
|
||||||
|
? sorted { $0.fullName < $1.fullName }
|
||||||
|
: sorted { $0.fullName > $1.fullName }
|
||||||
|
|
||||||
|
case .team:
|
||||||
|
return option == .ascending
|
||||||
|
? sorted { $0.team < $1.team || ($0.team == $1.team && $0.fullName < $1.fullName) }
|
||||||
|
: sorted { $0.team > $1.team || ($0.team == $1.team && $0.fullName < $1.fullName) }
|
||||||
|
|
||||||
|
case .employeeType:
|
||||||
|
return option == .ascending
|
||||||
|
? sorted { $0.employeeType.rawValue < $1.employeeType.rawValue || ($0.employeeType.rawValue == $1.employeeType.rawValue && $0.fullName < $1.fullName) }
|
||||||
|
: sorted { $0.employeeType.rawValue > $1.employeeType.rawValue || ($0.employeeType.rawValue == $1.employeeType.rawValue && $0.fullName < $1.fullName) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,14 +49,24 @@ public class NetworkService {
|
|||||||
/// - Throws: A `ServiceError` for network, decoding, or unexpected errors.
|
/// - Throws: A `ServiceError` for network, decoding, or unexpected errors.
|
||||||
/// - Returns: The decoded object of the specified type.
|
/// - Returns: The decoded object of the specified type.
|
||||||
public func fetchData<T: Decodable>(from endpoint: String, as type: T.Type) async throws -> T {
|
public func fetchData<T: Decodable>(from endpoint: String, as type: T.Type) async throws -> T {
|
||||||
do {
|
|
||||||
//ensure a valid URL
|
//ensure a valid URL
|
||||||
guard let url = URL(string: endpoint) else {
|
guard let url = URL(string: endpoint) else {
|
||||||
throw NetworkServiceError.invalidURL
|
throw NetworkServiceError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return try await fetchData(with: URLRequest(url: url), as: type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches data using a URLRequest and decodes it into a generic Decodable type.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - request: The URLRequest to execute.
|
||||||
|
/// - type: The type to decode the data into.
|
||||||
|
/// - Throws: A `NetworkServiceError` for network, decoding, or unexpected errors.
|
||||||
|
/// - Returns: The decoded object of the specified type.
|
||||||
|
public func fetchData<T: Decodable>(with request: URLRequest, as type: T.Type) async throws -> T {
|
||||||
|
do {
|
||||||
// Perform network request
|
// Perform network request
|
||||||
let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
// Validate HTTP response
|
// Validate HTTP response
|
||||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||||
|
|||||||
11007
EmployeeDirectory/Supporting Files/localTest.json
Normal file
11007
EmployeeDirectory/Supporting Files/localTest.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -31,24 +31,50 @@ 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?
|
||||||
|
|
||||||
|
// Prevents multiple calls during rapid scrolling
|
||||||
|
private var isFetchingNextPage: Bool = false
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
/// 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
|
/// 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.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
|
tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
|
||||||
tableView.dataSource = self
|
tableView.delegate = self
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
tableView.frame = view.bounds
|
tableView.frame = view.bounds
|
||||||
|
|
||||||
@ -64,26 +90,62 @@ class EmployeesViewController: UIViewController {
|
|||||||
modeSegmentedControl.selectedSegmentIndex = 0
|
modeSegmentedControl.selectedSegmentIndex = 0
|
||||||
modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged)
|
modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged)
|
||||||
navigationItem.titleView = modeSegmentedControl
|
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
|
/// 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?.applySnapshot(employees: employees)
|
||||||
self?.tableView.reloadData()
|
|
||||||
self?.tableView.refreshControl?.endRefreshing()
|
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
viewModel.$isLoading
|
viewModel.$isLoading
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
.sink { [weak self] isLoading in
|
.sink { [weak self] isLoading in
|
||||||
|
guard let self, let refreshControl = self.tableView.refreshControl else { return }
|
||||||
if isLoading {
|
if isLoading {
|
||||||
self?.activityIndicator.startAnimating()
|
activityIndicator.startAnimating()
|
||||||
} else {
|
} else {
|
||||||
self?.activityIndicator.stopAnimating()
|
activityIndicator.stopAnimating()
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
updateFooter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@ -98,29 +160,63 @@ class EmployeesViewController: UIViewController {
|
|||||||
|
|
||||||
/// Show state in specific use-cases for the EmployeesViewModel
|
/// Show state in specific use-cases for the EmployeesViewModel
|
||||||
private func updateFooter() {
|
private func updateFooter() {
|
||||||
var message: String? {
|
var footerMessage: String?
|
||||||
guard !viewModel.isLoading else { return nil }
|
|
||||||
return viewModel.errorMessage ?? (viewModel.employees.isEmpty ? "No employees found, please try to refresh." : nil)
|
// 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 message, !viewModel.isLoading {
|
if let footerMessage {
|
||||||
// Lazy initialize footerView if needed
|
// Lazy initialize footerView if needed
|
||||||
if footerView == nil {
|
if footerView == nil {
|
||||||
footerView = TableFooterView(message: message)
|
footerView = TableFooterView(message: footerMessage)
|
||||||
} else { // Update the message
|
} else {
|
||||||
footerView?.update(message: message)
|
footerView?.update(message: footerMessage)
|
||||||
}
|
}
|
||||||
footerView?.frame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: 150)
|
footerView?.frame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: 150)
|
||||||
tableView.tableFooterView = footerView
|
tableView.tableFooterView = footerView
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
tableView.tableFooterView = nil
|
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
|
// Mark: - Objective-C Methods
|
||||||
extension EmployeesViewController {
|
extension EmployeesViewController {
|
||||||
|
|
||||||
|
/// Show sort options
|
||||||
|
@objc private func showSortOptions() {
|
||||||
|
presentSortingActionSheet()
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the Employees
|
/// Fetch the Employees
|
||||||
@objc private func didPullToRefresh() {
|
@objc private func didPullToRefresh() {
|
||||||
viewModel.fetchEmployees()
|
viewModel.fetchEmployees()
|
||||||
@ -136,22 +232,6 @@ 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: UITableViewDataSource {
|
|
||||||
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
||||||
return viewModel.employees.count
|
|
||||||
}
|
|
||||||
|
|
||||||
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
||||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmployeeTableViewCell.identifier, for: indexPath) as? EmployeeTableViewCell else {
|
|
||||||
return UITableViewCell()
|
|
||||||
}
|
|
||||||
let employee = viewModel.employees[indexPath.row]
|
|
||||||
cell.configure(with: EmployeeCellViewModel(employee: employee))
|
|
||||||
return cell
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,46 +6,95 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
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 serviceMode: EmployeeServiceMode = .production
|
||||||
|
private var employeeService: EmployeeServiceProtocol = MockEmployeeService.shared
|
||||||
|
|
||||||
@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 private(set) var hasMorePages: Bool = true
|
||||||
|
|
||||||
public init() {}
|
@Published public var sortField: EmployeeSortField = .fullName
|
||||||
|
@Published public var sortOrder: EmployeeSortOrder = .ascending
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
public func fetchEmployees() {
|
private var currentPage = 1
|
||||||
// resetting values out the values before fetching new data
|
private let perPage = 10
|
||||||
|
private var totalEmployees = 0
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
observeSortingChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe changes to sortField and sortOrder and debounce fetch calls
|
||||||
|
private func observeSortingChanges() {
|
||||||
|
Publishers.CombineLatest($sortField, $sortOrder)
|
||||||
|
.dropFirst()
|
||||||
|
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _, _ in
|
||||||
|
self?.resetAndFetchEmployees()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch employees for the given page
|
||||||
|
public func fetchEmployees(page: Int = 1) {
|
||||||
|
|
||||||
|
// Prevent duplicate calls
|
||||||
|
guard !isLoading else { return }
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
// Fetch employees using the async method
|
// Fetch employees using the paginated API
|
||||||
let wrapper = try await EmployeeService.shared.getEmployees(serviceMode)
|
let wrapper = try await employeeService.getEmployees(page: page,
|
||||||
|
perPage: perPage,
|
||||||
|
sortField: sortField,
|
||||||
|
sortOrder: sortOrder)
|
||||||
|
|
||||||
|
totalEmployees = wrapper.total
|
||||||
|
currentPage = page
|
||||||
|
hasMorePages = wrapper.employees.count < totalEmployees
|
||||||
|
|
||||||
// Update published properties
|
// Update published properties
|
||||||
employees = wrapper.employees
|
if page == 1 {
|
||||||
isLoading = false
|
employees = wrapper.employees // Replace list for the first page
|
||||||
|
} else {
|
||||||
|
employees.append(contentsOf: wrapper.employees) // Append for subsequent pages
|
||||||
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
// Handle errors
|
// Handle errors
|
||||||
employees = []
|
errorMessage = "An unexpected error occurred, please try to refresh."
|
||||||
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
errorMessage = "An unexpected error occurred, please try to refresh"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load the next page of employees
|
||||||
|
public func loadNextPage() {
|
||||||
|
guard hasMorePages else { return }
|
||||||
|
fetchEmployees(page: currentPage + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func changeMode(to mode: EmployeeServiceMode) {
|
/// Resets the current employee list and fetches data from page 1
|
||||||
serviceMode = mode
|
private func resetAndFetchEmployees() {
|
||||||
fetchEmployees()
|
currentPage = 1
|
||||||
|
employees = []
|
||||||
|
hasMorePages = true
|
||||||
|
fetchEmployees(page: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func changeService(to employeeService: EmployeeServiceProtocol) {
|
||||||
|
self.employeeService = employeeService
|
||||||
|
resetAndFetchEmployees()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user