Compare commits

..

No commits in common. "285b448bcec2fa6579860de2028a7d10313772a5" and "9dcd4846cec944b55deecbd663f9b37c4c2238f8" have entirely different histories.

10 changed files with 32 additions and 11275 deletions

View File

@ -1,55 +0,0 @@
//
// 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
}
}

View File

@ -11,14 +11,8 @@ 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"
} }
} }

View File

@ -5,11 +5,12 @@
// 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. /// - Parameter serviceMode: Mode in which to hit.
/// - Returns: An Employees struct /// - Returns: An Employees struct
func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees
func getEmployees(_ serviceMode: EmployeeServiceMode, page: Int, perPage: Int, sortField: EmployeeSortField, sortOrder: EmployeeSortOrder) async throws -> Employees
} }

View File

@ -6,17 +6,6 @@
// //
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 { public enum EmployeeServiceMode: String, CaseIterable {
case production case production
@ -53,34 +42,4 @@ public class EmployeeService: EmployeeServiceProtocol {
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees { public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees {
return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self) 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)
}
} }

View File

@ -1,72 +0,0 @@
//
// 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) }
}
}
}

View File

@ -49,24 +49,14 @@ 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 {
//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 { do {
//ensure a valid URL
guard let url = URL(string: endpoint) else {
throw NetworkServiceError.invalidURL
}
// Perform network request // Perform network request
let (data, response) = try await session.data(for: request) let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
// 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 {

File diff suppressed because it is too large Load Diff

View File

@ -31,9 +31,6 @@ 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
// MARK: - Public Methods // MARK: - Public Methods
public override func viewDidLoad() { public override func viewDidLoad() {
@ -52,7 +49,6 @@ class EmployeesViewController: UIViewController {
// Configure TableView // Configure TableView
tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier) tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
tableView.dataSource = self tableView.dataSource = self
tableView.delegate = self
view.addSubview(tableView) view.addSubview(tableView)
tableView.frame = view.bounds tableView.frame = view.bounds
@ -102,55 +98,26 @@ 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 footerMessage: String? var message: String? {
guard !viewModel.isLoading else { return nil }
// Check for error messages or empty state first return viewModel.errorMessage ?? (viewModel.employees.isEmpty ? "No employees found, please try to refresh." : nil)
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 { if let message, !viewModel.isLoading {
// Lazy initialize footerView if needed // Lazy initialize footerView if needed
if footerView == nil { if footerView == nil {
footerView = TableFooterView(message: footerMessage) footerView = TableFooterView(message: message)
} else { } else { // Update the message
footerView?.update(message: footerMessage) footerView?.update(message: message)
} }
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 {

View File

@ -16,57 +16,36 @@ public class EmployeesViewModel: ObservableObject {
@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
private var currentPage = 1
private let perPage = 10
private var totalEmployees = 0
public init() {} public init() {}
/// Fetch employees for the given page public func fetchEmployees() {
public func fetchEmployees(page: Int = 1) { // resetting values out the values before fetching new data
// Prevent duplicate calls errorMessage = nil
guard !isLoading else { return }
isLoading = true isLoading = true
Task { Task {
do { do {
// Fetch employees using the paginated API // Fetch employees using the async method
let wrapper = try await MockEmployeeService.shared.getEmployees(.empty ,page: page, perPage: perPage) let wrapper = try await EmployeeService.shared.getEmployees(serviceMode)
// Update published properties // Update published properties
if page == 1 { employees = wrapper.employees
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 isLoading = false
} catch { } catch {
// Handle errors // Handle errors
employees = []
isLoading = false 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) { public func changeMode(to mode: EmployeeServiceMode) {
serviceMode = mode serviceMode = mode
currentPage = 1 fetchEmployees()
employees = []
hasMorePages = true
fetchEmployees(page: 1)
} }
} }

View File

@ -128,10 +128,11 @@ 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 if let image {
self?.photoImageView.image = image
}
} }
// Bind data to UI components // Bind data to UI components