Compare commits
10 Commits
9dcd4846ce
...
285b448bce
| Author | SHA1 | Date | |
|---|---|---|---|
| 285b448bce | |||
| 31c4f08303 | |||
| 68ab23aefd | |||
| 7e0c2fd310 | |||
| 427e862b6b | |||
| e5175dd84e | |||
| 4ae060597c | |||
| 801830e5b8 | |||
| 13de040621 | |||
| 3314304b46 |
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
|
||||
}
|
||||
}
|
||||
@ -11,8 +11,14 @@ public struct Employees: Codable {
|
||||
|
||||
/// Array of Employees
|
||||
public var employees: [Employee]
|
||||
|
||||
public let total: Int
|
||||
public let page: Int
|
||||
public let perPage: Int
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case employees
|
||||
case total
|
||||
case page
|
||||
case perPage = "per_page"
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,12 +5,11 @@
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
|
||||
|
||||
/// This will be the interface for the API for Employees
|
||||
public protocol EmployeeServiceProtocol {
|
||||
|
||||
/// This will get a list of all employees
|
||||
/// - Parameter serviceMode: Mode in which to hit.
|
||||
/// - Returns: An Employees struct
|
||||
func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees
|
||||
func getEmployees(_ serviceMode: EmployeeServiceMode, page: Int, perPage: Int, sortField: EmployeeSortField, sortOrder: EmployeeSortOrder) async throws -> Employees
|
||||
}
|
||||
|
||||
@ -6,6 +6,17 @@
|
||||
//
|
||||
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
|
||||
public enum EmployeeServiceMode: String, CaseIterable {
|
||||
case production
|
||||
@ -42,4 +53,34 @@ public class EmployeeService: EmployeeServiceProtocol {
|
||||
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees {
|
||||
return try await NetworkService.shared.fetchData(from: serviceMode.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(_ serviceMode: EmployeeServiceMode = .production,
|
||||
page: Int, perPage: Int,
|
||||
sortField: EmployeeSortField = .fullName,
|
||||
sortOrder: EmployeeSortOrder = .ascending) async throws -> Employees {
|
||||
|
||||
guard var urlComponents = URLComponents(string: serviceMode.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)
|
||||
}
|
||||
}
|
||||
|
||||
72
EmployeeDirectory/Services/MockEmployeeService.swift
Normal file
72
EmployeeDirectory/Services/MockEmployeeService.swift
Normal file
@ -0,0 +1,72 @@
|
||||
//
|
||||
// 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 employees: Employees
|
||||
|
||||
// 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 {
|
||||
employees = .init(employees: [], total: 0, page: 0, perPage: 0)
|
||||
return
|
||||
}
|
||||
employees = localData
|
||||
}
|
||||
|
||||
public func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees {
|
||||
return employees
|
||||
}
|
||||
|
||||
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production,
|
||||
page: Int, perPage: Int,
|
||||
sortField: EmployeeSortField = .team,
|
||||
sortOrder: EmployeeSortOrder = .ascending) async throws -> Employees {
|
||||
|
||||
employees = .init(employees: employees.employees.sorted(by: sortField, with: sortOrder),
|
||||
total: employees.employees.count,
|
||||
page: page,
|
||||
perPage: perPage)
|
||||
|
||||
let totalUsers = employees.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
|
||||
}
|
||||
let paginatedUsers = Array(employees.employees[startIndex..<endIndex])
|
||||
return .init(employees: paginatedUsers, total: totalUsers, page: page, perPage: perPage)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
/// - Returns: The decoded object of the specified type.
|
||||
public func fetchData<T: Decodable>(from endpoint: String, as type: T.Type) async throws -> T {
|
||||
//ensure a valid URL
|
||||
guard let url = URL(string: endpoint) else {
|
||||
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 {
|
||||
//ensure a valid URL
|
||||
guard let url = URL(string: endpoint) else {
|
||||
throw NetworkServiceError.invalidURL
|
||||
}
|
||||
|
||||
// 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
|
||||
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,6 +31,9 @@ class EmployeesViewController: UIViewController {
|
||||
/// Will show specific state of the viewModel
|
||||
private var footerView: TableFooterView?
|
||||
|
||||
// Prevents multiple calls during rapid scrolling
|
||||
private var isFetchingNextPage: Bool = false
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
public override func viewDidLoad() {
|
||||
@ -49,6 +52,7 @@ class EmployeesViewController: UIViewController {
|
||||
// Configure TableView
|
||||
tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
view.addSubview(tableView)
|
||||
tableView.frame = view.bounds
|
||||
|
||||
@ -98,26 +102,55 @@ class EmployeesViewController: UIViewController {
|
||||
|
||||
/// Show state in specific use-cases for the EmployeesViewModel
|
||||
private func updateFooter() {
|
||||
var message: String? {
|
||||
guard !viewModel.isLoading else { return nil }
|
||||
return viewModel.errorMessage ?? (viewModel.employees.isEmpty ? "No employees found, please try to refresh." : nil)
|
||||
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 message, !viewModel.isLoading {
|
||||
if let footerMessage {
|
||||
// Lazy initialize footerView if needed
|
||||
if footerView == nil {
|
||||
footerView = TableFooterView(message: message)
|
||||
} else { // Update the message
|
||||
footerView?.update(message: message)
|
||||
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 {
|
||||
|
||||
|
||||
@ -16,36 +16,57 @@ public class EmployeesViewModel: ObservableObject {
|
||||
@Published public private(set) var employees: [Employee] = []
|
||||
@Published public private(set) var errorMessage: String? = nil
|
||||
@Published public private(set) var isLoading: Bool = false
|
||||
|
||||
@Published public private(set) var hasMorePages: Bool = true
|
||||
|
||||
private var currentPage = 1
|
||||
private let perPage = 10
|
||||
private var totalEmployees = 0
|
||||
|
||||
public init() {}
|
||||
|
||||
public func fetchEmployees() {
|
||||
// resetting values out the values before fetching new data
|
||||
errorMessage = nil
|
||||
|
||||
/// Fetch employees for the given page
|
||||
public func fetchEmployees(page: Int = 1) {
|
||||
// Prevent duplicate calls
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Fetch employees using the async method
|
||||
let wrapper = try await EmployeeService.shared.getEmployees(serviceMode)
|
||||
// Fetch employees using the paginated API
|
||||
let wrapper = try await MockEmployeeService.shared.getEmployees(.empty ,page: page, perPage: perPage)
|
||||
|
||||
// Update published properties
|
||||
employees = wrapper.employees
|
||||
if page == 1 {
|
||||
employees = wrapper.employees // Replace list for the first page
|
||||
} else {
|
||||
employees.append(contentsOf: wrapper.employees) // Append for subsequent pages
|
||||
}
|
||||
|
||||
totalEmployees = wrapper.total
|
||||
currentPage = page
|
||||
hasMorePages = employees.count < totalEmployees
|
||||
isLoading = false
|
||||
|
||||
} catch {
|
||||
// Handle errors
|
||||
employees = []
|
||||
isLoading = false
|
||||
errorMessage = "An unexpected error occurred, please try to refresh"
|
||||
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)
|
||||
}
|
||||
|
||||
/// Change the service mode (e.g., production, malformed, empty)
|
||||
public func changeMode(to mode: EmployeeServiceMode) {
|
||||
serviceMode = mode
|
||||
fetchEmployees()
|
||||
currentPage = 1
|
||||
employees = []
|
||||
hasMorePages = true
|
||||
fetchEmployees(page: 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -128,11 +128,10 @@ public class EmployeeTableViewCell: UITableViewCell {
|
||||
|
||||
// Bind the image to the photoImageView
|
||||
smallPhotoSubscriber = viewModel.$smallPhoto
|
||||
.compactMap { $0 }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] image in
|
||||
if let image {
|
||||
self?.photoImageView.image = image
|
||||
}
|
||||
self?.photoImageView.image = image
|
||||
}
|
||||
|
||||
// Bind data to UI components
|
||||
|
||||
Loading…
Reference in New Issue
Block a user