Compare commits
No commits in common. "main" and "dd084d13b4938c2ca638a8e38cc171d1677de4df" have entirely different histories.
main
...
dd084d13b4
@ -6,10 +6,6 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
EA668AC12D3F04D600E021EA /* README.MD in Resources */ = {isa = PBXBuildFile; fileRef = EA668AC02D3F04D600E021EA /* README.MD */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
EA668AA02D3F037F00E021EA /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
@ -31,7 +27,6 @@
|
||||
EA668A892D3F037E00E021EA /* EmployeeDirectory.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EmployeeDirectory.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA668A9F2D3F037F00E021EA /* EmployeeDirectoryTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EmployeeDirectoryTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA668AA92D3F037F00E021EA /* EmployeeDirectoryUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EmployeeDirectoryUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA668AC02D3F04D600E021EA /* README.MD */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.MD; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -93,7 +88,6 @@
|
||||
EA668A802D3F037E00E021EA = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EA668AC02D3F04D600E021EA /* README.MD */,
|
||||
EA668A8B2D3F037E00E021EA /* EmployeeDirectory */,
|
||||
EA668AA22D3F037F00E021EA /* EmployeeDirectoryTests */,
|
||||
EA668AAC2D3F037F00E021EA /* EmployeeDirectoryUITests */,
|
||||
@ -231,7 +225,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EA668AC12D3F04D600E021EA /* README.MD in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -301,7 +294,8 @@
|
||||
INFOPLIST_FILE = EmployeeDirectory/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@ -327,7 +321,8 @@
|
||||
INFOPLIST_FILE = EmployeeDirectory/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
//
|
||||
// String.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
/// Formats a string into a US phone number format (XXX-XXX-XXXX).
|
||||
/// Non-numeric characters are removed, and formatting is applied based on the length of the string.
|
||||
/// - Returns: A formatted phone number as a string.
|
||||
internal func formatUSNumber() -> String {
|
||||
// mask for the phone numver
|
||||
let mask = "XXX-XXX-XXXX"
|
||||
|
||||
let numbers = filter { $0.isNumber }
|
||||
var result = ""
|
||||
var index = numbers.startIndex // numbers iterator
|
||||
|
||||
// iterate over the mask characters until the iterator of numbers ends
|
||||
for ch in mask where index < numbers.endIndex {
|
||||
if ch == "X" {
|
||||
// mask requires a number in this place, so take the next one
|
||||
result.append(numbers[index])
|
||||
|
||||
// move numbers iterator to the next index
|
||||
index = numbers.index(after: index)
|
||||
|
||||
} else {
|
||||
result.append(ch) // just append a mask character
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
//
|
||||
// URL.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
extension URL {
|
||||
|
||||
/// This will has the URL absoluteString into a that is consistent.
|
||||
internal var uniqueIdentifier: String {
|
||||
let data = Data(absoluteString.utf8)
|
||||
let hash = SHA256.hash(data: data)
|
||||
return hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
//
|
||||
// Employee.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// EmployeeType enum
|
||||
/// - How the employee is classified.
|
||||
public enum EmployeeType: String, Codable, CustomStringConvertible {
|
||||
case fullTime = "FULL_TIME"
|
||||
case partTime = "PART_TIME"
|
||||
case contractor = "CONTRACTOR"
|
||||
|
||||
/// Format the employee type for display
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .fullTime: return "Full-Time"
|
||||
case .partTime: return "Part-Time"
|
||||
case .contractor: return "Contractor"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Employee Object
|
||||
/// JSON Object defintion
|
||||
/// - https://square.github.io/microsite/mobile-interview-project/
|
||||
public struct Employee: Codable {
|
||||
|
||||
/// The unique identifier for the employee. Represented as a UUID.
|
||||
public let uuid: UUID
|
||||
|
||||
/// The full name of the employee.
|
||||
public let fullName: String
|
||||
|
||||
/// The phone number of the employee, sent as an unformatted string (eg, 5556661234).
|
||||
public let phoneNumber: String?
|
||||
|
||||
/// The email address of the employee.
|
||||
public let emailAddress: String
|
||||
|
||||
/// A short, tweet-length (~300 chars) string that the employee provided to describe themselves.
|
||||
public let biography: String?
|
||||
|
||||
/// The URL of the employee’s small photo. Useful for list view.
|
||||
public let photoURLSmall: String?
|
||||
|
||||
/// The URL of the employee’s full-size photo.
|
||||
public let photoURLLarge: String?
|
||||
|
||||
/// The team they are on, represented as a human readable string.
|
||||
public let team: String
|
||||
|
||||
/// How the employee is classified.
|
||||
public let employeeType: EmployeeType
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case uuid
|
||||
case fullName = "full_name"
|
||||
case phoneNumber = "phone_number"
|
||||
case emailAddress = "email_address"
|
||||
case biography
|
||||
case photoURLSmall = "photo_url_small"
|
||||
case photoURLLarge = "photo_url_large"
|
||||
case team
|
||||
case employeeType = "employee_type"
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
//
|
||||
// Employees.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
import Foundation
|
||||
|
||||
/// Wrapper JSON Class for the Employees
|
||||
public struct Employees: Codable {
|
||||
|
||||
/// Array of Employees
|
||||
public var employees: [Employee]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case employees
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
//
|
||||
// EmployeeServiceable.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// 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
|
||||
}
|
||||
18
EmployeeDirectory/README.MD
Normal file
18
EmployeeDirectory/README.MD
Normal file
@ -0,0 +1,18 @@
|
||||
## Build tools & versions used
|
||||
|
||||
## Steps to run the app
|
||||
|
||||
## What areas of the app did you focus on?
|
||||
|
||||
## What was the reason for your focus? What problems were you trying to solve?
|
||||
|
||||
## How long did you spend on this project?
|
||||
|
||||
## Did you make any trade-offs for this project? What would you have done differently with more time?
|
||||
|
||||
## What do you think is the weakest part of your project?
|
||||
|
||||
## Did you copy any code or dependencies? Please make sure to attribute them here!
|
||||
|
||||
## Is there any other information you’d like us to know?
|
||||
|
||||
@ -21,7 +21,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
|
||||
// Set up the root view controller
|
||||
let employeeListVC = EmployeesViewController()
|
||||
let employeeListVC = ViewController()
|
||||
let navigationController = UINavigationController(rootViewController: employeeListVC)
|
||||
window.rootViewController = navigationController
|
||||
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
//
|
||||
// EmployeeService.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
import Foundation
|
||||
|
||||
/// These are the testing URL Endpoints for different states
|
||||
public enum EmployeeServiceMode: String, CaseIterable {
|
||||
case production
|
||||
case malformed
|
||||
case empty
|
||||
|
||||
/// Enpoint in which to grabe employees from.
|
||||
public var endpoint: String {
|
||||
switch self {
|
||||
case .production:
|
||||
return "https://s3.amazonaws.com/sq-mobile-interview/employees.json"
|
||||
case .malformed:
|
||||
return "https://s3.amazonaws.com/sq-mobile-interview/employees_malformed.json"
|
||||
case .empty:
|
||||
return "https://s3.amazonaws.com/sq-mobile-interview/employees_empty.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service Layer for Employees
|
||||
public class EmployeeService: EmployeeServiceProtocol {
|
||||
// MARK: - Properties
|
||||
public static let shared = EmployeeService() // 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(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees {
|
||||
return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self)
|
||||
}
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
//
|
||||
// ImageCacheService.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// A service that handles image caching using memory, disk, and network in priority order.
|
||||
public class ImageCacheService {
|
||||
// MARK: - Properties
|
||||
public static let shared = ImageCacheService() // Default shared instance
|
||||
|
||||
/// Memory cache for storing images in RAM.
|
||||
private let memoryCache = NSCache<NSString, UIImage>()
|
||||
|
||||
/// File manager for handling disk operations.
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
/// Directory where cached images are stored on disk.
|
||||
private let cacheDirectory: URL = {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
}()
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Loads an image from memory, disk, or network.
|
||||
///
|
||||
/// - Parameter url: The URL of the image to load.
|
||||
/// - Returns: The loaded `UIImage` or `nil` if the image could not be loaded.
|
||||
public func loadImage(from url: URL) async -> UIImage? {
|
||||
let uniqueKey = url.uniqueIdentifier
|
||||
let cacheKey = uniqueKey as NSString
|
||||
|
||||
// Step 1: Check the memory cache
|
||||
if let cachedImage = memoryCache.object(forKey: cacheKey) {
|
||||
return cachedImage
|
||||
}
|
||||
|
||||
// Step 2: Check the disk cache
|
||||
let diskImagePath = cacheDirectory.appendingPathComponent(uniqueKey)
|
||||
if let diskImage = UIImage(contentsOfFile: diskImagePath.path) {
|
||||
memoryCache.setObject(diskImage, forKey: cacheKey) // Cache in memory for faster access next time
|
||||
return diskImage
|
||||
}
|
||||
|
||||
// Step 3: Fetch the image from the network
|
||||
if let networkImage = await fetchFromNetwork(url: url) {
|
||||
memoryCache.setObject(networkImage, forKey: cacheKey) // Cache in memory
|
||||
try? saveImageToDisk(image: networkImage, at: diskImagePath) // Cache on disk
|
||||
return networkImage
|
||||
}
|
||||
|
||||
// Step 4: Return nil if all options fail
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Fetches an image from the network.
|
||||
///
|
||||
/// - Parameter url: The URL of the image to fetch.
|
||||
/// - Returns: The fetched `UIImage` or `nil` if the network request fails.
|
||||
private func fetchFromNetwork(url: URL) async -> UIImage? {
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
return UIImage(data: data)
|
||||
} catch {
|
||||
print("Failed to fetch image from network for URL: \(url). Error: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves an image to disk at the specified path.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - image: The `UIImage` to save.
|
||||
/// - path: The file path where the image should be saved.
|
||||
private func saveImageToDisk(image: UIImage, at path: URL) throws {
|
||||
guard let data = image.pngData() else { return }
|
||||
try data.write(to: path)
|
||||
}
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
//
|
||||
// NetworkService.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum NetworkServiceError: Error {
|
||||
/// The response from the server was invalid (e.g., non-200 status code or malformed URL).
|
||||
case invalidResponse
|
||||
|
||||
/// The url giving is invalid or malformed
|
||||
case invalidURL
|
||||
|
||||
/// The data received was invalid or could not be decoded.
|
||||
case decodingError(DecodingError)
|
||||
|
||||
/// A network-related error occurred.
|
||||
case networkError(URLError)
|
||||
|
||||
/// An unexpected, uncategorized error occurred.
|
||||
case unknownError(Error)
|
||||
}
|
||||
|
||||
public class NetworkService {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
public static let shared = NetworkService() // Default shared instance
|
||||
|
||||
private let session: URLSession
|
||||
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
/// Public initializer to allow customization
|
||||
public init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Fetches data from a URL and decodes it into a generic Decodable type.
|
||||
/// - Parameters:
|
||||
/// - endpoint: The url to fetch data from.
|
||||
/// - type: The type to decode the data into.
|
||||
/// - 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 {
|
||||
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))
|
||||
|
||||
// Validate HTTP response
|
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||
throw NetworkServiceError.invalidResponse
|
||||
}
|
||||
|
||||
// Decode the response into the specified type
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
|
||||
} catch let urlError as URLError {
|
||||
throw NetworkServiceError.networkError(urlError)
|
||||
|
||||
} catch let decodingError as DecodingError {
|
||||
throw NetworkServiceError.decodingError(decodingError)
|
||||
|
||||
} catch {
|
||||
throw NetworkServiceError.unknownError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,157 +0,0 @@
|
||||
//
|
||||
// ViewController.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
class EmployeesViewController: UIViewController {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
|
||||
/// List for the employees
|
||||
private let tableView = UITableView()
|
||||
|
||||
/// Will only show when fetching data occurs
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
/// Allows the user to pick between service modes
|
||||
private let modeSegmentedControl = UISegmentedControl(items: EmployeeServiceMode.allCases.map{ $0.rawValue } )
|
||||
|
||||
/// ViewModel in which drives the screen
|
||||
private let viewModel = EmployeesViewModel()
|
||||
|
||||
/// Holds onto the ViewModels Subscribers
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Will show specific state of the viewModel
|
||||
private var footerView: TableFooterView?
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
bindViewModel()
|
||||
viewModel.fetchEmployees()
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Setup the UI by adding the views to the main view
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .white
|
||||
|
||||
// Configure TableView
|
||||
tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
|
||||
tableView.dataSource = self
|
||||
view.addSubview(tableView)
|
||||
tableView.frame = view.bounds
|
||||
|
||||
//add pull to refresh
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged)
|
||||
|
||||
// Configure Activity Indicator
|
||||
activityIndicator.center = view.center
|
||||
view.addSubview(activityIndicator)
|
||||
|
||||
// Configure Mode Selector
|
||||
modeSegmentedControl.selectedSegmentIndex = 0
|
||||
modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged)
|
||||
navigationItem.titleView = modeSegmentedControl
|
||||
}
|
||||
|
||||
/// Using the ViewModel setup combine handlers
|
||||
private func bindViewModel() {
|
||||
viewModel.$employees
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updateFooter()
|
||||
self?.tableView.reloadData()
|
||||
self?.tableView.refreshControl?.endRefreshing()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$isLoading
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] isLoading in
|
||||
if isLoading {
|
||||
self?.activityIndicator.startAnimating()
|
||||
} else {
|
||||
self?.activityIndicator.stopAnimating()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
viewModel.$errorMessage
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.updateFooter()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
if let message, !viewModel.isLoading {
|
||||
// Lazy initialize footerView if needed
|
||||
if footerView == nil {
|
||||
footerView = TableFooterView(message: message)
|
||||
} else { // Update the message
|
||||
footerView?.update(message: message)
|
||||
}
|
||||
footerView?.frame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: 150)
|
||||
tableView.tableFooterView = footerView
|
||||
} else {
|
||||
tableView.tableFooterView = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark: - Objective-C Methods
|
||||
extension EmployeesViewController {
|
||||
|
||||
/// Fetch the Employees
|
||||
@objc private func didPullToRefresh() {
|
||||
viewModel.fetchEmployees()
|
||||
}
|
||||
|
||||
/// This will handle services changes to test conditions to ensure UI works correctly.
|
||||
/// - Parameter sender: Mode in which to test
|
||||
@objc private func onServiceModeChange(_ sender: UISegmentedControl) {
|
||||
let selectedMode: EmployeeServiceMode
|
||||
switch sender.selectedSegmentIndex {
|
||||
case 0: selectedMode = .production
|
||||
case 1: selectedMode = .malformed
|
||||
case 2: selectedMode = .empty
|
||||
default: return
|
||||
}
|
||||
viewModel.changeMode(to: selectedMode)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
19
EmployeeDirectory/ViewControllers/ViewController.swift
Normal file
19
EmployeeDirectory/ViewControllers/ViewController.swift
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// ViewController.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ViewController: UIViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
// Do any additional setup after loading the view.
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -1,50 +0,0 @@
|
||||
//
|
||||
// EmployeeCellViewModel.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// ViewModel that will be used along with the EmployeeTableViewCell.
|
||||
@MainActor
|
||||
public class EmployeeCellViewModel: ObservableObject {
|
||||
// MARK: - Properties
|
||||
|
||||
private let employee: Employee
|
||||
|
||||
public private(set) var uuid: String
|
||||
public private(set) var fullName: String
|
||||
public private(set) var phoneNumber: String?
|
||||
public private(set) var emailAddress: String
|
||||
public private(set) var biography: String?
|
||||
public private(set) var team: String
|
||||
public private(set) var employeeType: String
|
||||
@Published public private(set) var smallPhoto: UIImage?
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
public init(employee: Employee) {
|
||||
self.employee = employee
|
||||
|
||||
// Initialize properties
|
||||
uuid = employee.uuid.uuidString
|
||||
fullName = employee.fullName
|
||||
phoneNumber = employee.phoneNumber?.formatUSNumber()
|
||||
emailAddress = employee.emailAddress
|
||||
biography = employee.biography
|
||||
team = employee.team
|
||||
employeeType = employee.employeeType.description
|
||||
|
||||
// Fetch the image for the url if it exists
|
||||
if let endpoint = employee.photoURLSmall {
|
||||
Task{
|
||||
if let smallPhotoURL = URL(string: endpoint) {
|
||||
smallPhoto = await ImageCacheService.shared.loadImage(from: smallPhotoURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
//
|
||||
// EmployeesViewModel.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// ViewModel that will be bound to an Employees model and used
|
||||
/// specifically with the EmployeesViewController.
|
||||
@MainActor
|
||||
public class EmployeesViewModel: ObservableObject {
|
||||
private var serviceMode: EmployeeServiceMode = .production
|
||||
|
||||
@Published public private(set) var employees: [Employee] = []
|
||||
@Published public private(set) var errorMessage: String? = nil
|
||||
@Published public private(set) var isLoading: Bool = false
|
||||
|
||||
public init() {}
|
||||
|
||||
public func fetchEmployees() {
|
||||
// resetting values out the values before fetching new data
|
||||
errorMessage = nil
|
||||
isLoading = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Fetch employees using the async method
|
||||
let wrapper = try await EmployeeService.shared.getEmployees(serviceMode)
|
||||
|
||||
// Update published properties
|
||||
employees = wrapper.employees
|
||||
isLoading = false
|
||||
|
||||
} catch {
|
||||
// Handle errors
|
||||
employees = []
|
||||
isLoading = false
|
||||
errorMessage = "An unexpected error occurred, please try to refresh"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public func changeMode(to mode: EmployeeServiceMode) {
|
||||
serviceMode = mode
|
||||
fetchEmployees()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,149 +0,0 @@
|
||||
//
|
||||
// EmployeeTableViewCell.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
/// This is the Cell used in the EmployeesTableViewController to show
|
||||
/// the properties of an Employee model.
|
||||
public class EmployeeTableViewCell: UITableViewCell {
|
||||
|
||||
/// Used in the TableView registration
|
||||
static let identifier = "EmployeeTableViewCell"
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
|
||||
/// UI Elements
|
||||
private let photoImageView = UIImageView()
|
||||
private let nameLabel = UILabel()
|
||||
private let emailLabel = UILabel()
|
||||
private let teamLabel = UILabel()
|
||||
private let employeeTypeLabel = UILabel()
|
||||
private let phoneLabel = UILabel()
|
||||
private let bioLabel = UILabel()
|
||||
private let stackView = UIStackView()
|
||||
|
||||
/// Used for grabbing the photo
|
||||
private var smallPhotoSubscriber: AnyCancellable?
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func setupUI() {
|
||||
// Configure photoImageView
|
||||
photoImageView.contentMode = .scaleAspectFill
|
||||
photoImageView.clipsToBounds = true
|
||||
photoImageView.layer.cornerRadius = 25
|
||||
photoImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
photoImageView.image = UIImage(systemName: "person.crop.circle")
|
||||
|
||||
// Configure labels with text styles
|
||||
nameLabel.font = UIFont.preferredFont(forTextStyle: .headline) // Bold, prominent text
|
||||
nameLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
|
||||
|
||||
emailLabel.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
emailLabel.textColor = .blue
|
||||
emailLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
|
||||
|
||||
teamLabel.font = UIFont.preferredFont(forTextStyle: .subheadline) // Secondary, smaller text
|
||||
teamLabel.textColor = .gray
|
||||
teamLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
|
||||
|
||||
employeeTypeLabel.font = UIFont.preferredFont(forTextStyle: .subheadline)
|
||||
employeeTypeLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
|
||||
|
||||
phoneLabel.font = UIFont.preferredFont(forTextStyle: .body) // Standard body text
|
||||
phoneLabel.textColor = .darkGray
|
||||
phoneLabel.adjustsFontForContentSizeCategory = true// Adapts to Dynamic Type
|
||||
|
||||
bioLabel.font = UIFont.preferredFont(forTextStyle: .footnote) // Smaller text for additional info
|
||||
bioLabel.numberOfLines = 0
|
||||
bioLabel.textColor = .lightGray
|
||||
bioLabel.adjustsFontForContentSizeCategory = true // Adapts to Dynamic Type
|
||||
|
||||
// Configure stackView
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 5
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Add labels to stackView
|
||||
stackView.addArrangedSubview(nameLabel)
|
||||
stackView.addArrangedSubview(teamLabel)
|
||||
stackView.addArrangedSubview(employeeTypeLabel)
|
||||
stackView.addArrangedSubview(phoneLabel)
|
||||
stackView.addArrangedSubview(emailLabel)
|
||||
stackView.addArrangedSubview(bioLabel)
|
||||
|
||||
// Add subviews
|
||||
contentView.addSubview(photoImageView)
|
||||
contentView.addSubview(stackView)
|
||||
|
||||
// Add constraints
|
||||
NSLayoutConstraint.activate([
|
||||
photoImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
|
||||
photoImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
photoImageView.widthAnchor.constraint(equalToConstant: 50),
|
||||
photoImageView.heightAnchor.constraint(equalToConstant: 50),
|
||||
|
||||
stackView.leadingAnchor.constraint(equalTo: photoImageView.trailingAnchor, constant: 10),
|
||||
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
|
||||
stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
|
||||
stackView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -10)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
|
||||
/// Override for setting back to a default state
|
||||
public override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
smallPhotoSubscriber = nil
|
||||
photoImageView.image = UIImage(systemName: "person.crop.circle")
|
||||
nameLabel.text = nil
|
||||
emailLabel.text = nil
|
||||
teamLabel.text = nil
|
||||
employeeTypeLabel.text = nil
|
||||
phoneLabel.text = nil
|
||||
bioLabel.text = nil
|
||||
}
|
||||
|
||||
/// Configures the UI Elements with the properties of the EmployeeCellViewModel.
|
||||
/// - Parameter viewModel: Employee Model wrapped for Cell.
|
||||
public func configure(with viewModel: EmployeeCellViewModel) {
|
||||
|
||||
// Bind the image to the photoImageView
|
||||
smallPhotoSubscriber = viewModel.$smallPhoto
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] image in
|
||||
self?.photoImageView.image = image
|
||||
}
|
||||
|
||||
// Bind data to UI components
|
||||
nameLabel.text = viewModel.fullName
|
||||
emailLabel.text = viewModel.emailAddress
|
||||
teamLabel.text = viewModel.team
|
||||
employeeTypeLabel.text = viewModel.employeeType
|
||||
phoneLabel.text = viewModel.phoneNumber
|
||||
bioLabel.text = viewModel.biography
|
||||
|
||||
// Dynamically show or hide elements based on their content
|
||||
phoneLabel.isHidden = viewModel.phoneNumber == nil
|
||||
bioLabel.isHidden = viewModel.biography == nil
|
||||
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
//
|
||||
// EmptyStateFooterView.swift
|
||||
// EmployeeDirectory
|
||||
//
|
||||
// Created by Matt Bruce on 1/20/25.
|
||||
//
|
||||
import UIKit
|
||||
|
||||
/// Meant to be used as a Message for State in a TableView.
|
||||
public class TableFooterView: UIView {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// Label used to show the message
|
||||
private let messageLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textColor = .gray
|
||||
label.textAlignment = .center
|
||||
label.font = UIFont.preferredFont(forTextStyle: .body) // Use a text style for Dynamic Type
|
||||
label.adjustsFontForContentSizeCategory = true // Enable Dynamic Type adjustments
|
||||
label.numberOfLines = 0
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}()
|
||||
|
||||
// MARK: - Initializer
|
||||
|
||||
init(message: String) {
|
||||
super.init(frame: .zero)
|
||||
setupUI()
|
||||
update(message: message)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// Setup the UI
|
||||
private func setupUI() {
|
||||
addSubview(messageLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
messageLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
messageLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
messageLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
|
||||
messageLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Updates the Current Message
|
||||
/// - Parameter message: message to show
|
||||
public func update(message: String) {
|
||||
messageLabel.text = message
|
||||
}
|
||||
|
||||
}
|
||||
@ -6,64 +6,12 @@
|
||||
//
|
||||
|
||||
import Testing
|
||||
|
||||
@testable import EmployeeDirectory
|
||||
|
||||
struct EmployeeDirectoryTests {
|
||||
|
||||
@Test func getEmployeesValid() async throws {
|
||||
do {
|
||||
let wrapper = try await EmployeeService.shared.getEmployees(.production)
|
||||
#expect(wrapper.employees.count == 11)
|
||||
} catch {
|
||||
#expect(Bool(false), "Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func getEmployeesMalformed() async throws {
|
||||
do {
|
||||
_ = try await EmployeeService.shared.getEmployees(.malformed)
|
||||
#expect(Bool(false), "Expected invalidResponse error, but no error was thrown")
|
||||
} catch let error as NetworkServiceError {
|
||||
switch error {
|
||||
case .decodingError(let decodingError):
|
||||
#expect(Bool(true), "Expected NetworkServiceError.decodingError, but got \(decodingError)")
|
||||
default:
|
||||
#expect(Bool(false), "Expected NetworkServiceError.decodingError, but got \(error)")
|
||||
}
|
||||
} catch {
|
||||
#expect(Bool(false), "Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Test func getEmployeesEmpty() async throws {
|
||||
do {
|
||||
let wrapper = try await EmployeeService.shared.getEmployees(.empty)
|
||||
#expect(wrapper.employees.count == 0)
|
||||
} catch {
|
||||
#expect(Bool(false), "Unexpected error: \(error)")
|
||||
}
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Testing extension since this isn't needed in the code.
|
||||
extension NetworkServiceError: @retroactive Equatable {
|
||||
public static func == (lhs: NetworkServiceError, rhs: NetworkServiceError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.invalidResponse, .invalidResponse):
|
||||
return true
|
||||
case (.invalidURL, .invalidURL):
|
||||
return true
|
||||
case (.decodingError(let lhsError), .decodingError(let rhsError)):
|
||||
return lhsError.localizedDescription == rhsError.localizedDescription
|
||||
case (.networkError(let lhsError), .networkError(let rhsError)):
|
||||
return lhsError.code == rhsError.code
|
||||
case (.unknownError(let lhsError), .unknownError(let rhsError)):
|
||||
return lhsError.localizedDescription == rhsError.localizedDescription
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
README.MD
39
README.MD
@ -1,39 +0,0 @@
|
||||
# Employee Directory
|
||||
|
||||
## Build tools & versions used
|
||||
Xcode 16.2, built with Swift 5.9.
|
||||
|
||||
## Steps to run the app
|
||||
No additional steps are required. Simply build and run the project. The app supports iPhone and iPad in portrait mode.
|
||||
|
||||
## What areas of the app did you focus on?
|
||||
I focused on designing a robust architecture using a separation of concerns approach. I also implemented a functional UI for the employee directory.
|
||||
|
||||
## What was the reason for your focus? What problems were you trying to solve?
|
||||
My primary goal was to ensure the app's architecture was clean, modular, and easy to maintain or extend. I aimed to minimize tightly coupled code and ensure future features could be added with minimal changes. This approach improves testability and makes the codebase easier for others to understand and work with.
|
||||
|
||||
## How long did you spend on this project?
|
||||
Approximately 5 hours:
|
||||
- 4 hours on development and testing.
|
||||
- 1 hour on code refinement, documentation, and ensuring best practices.
|
||||
|
||||
## Did you make any trade-offs for this project? What would you have done differently with more time?
|
||||
**Trade-offs**:
|
||||
- The networking layer currently lacks a request builder. This limits the ability to add query parameters or authentication headers in a structured way.
|
||||
- The UI uses standard UIKit components, which could be improved for a more modern or visually appealing design.
|
||||
|
||||
**With more time**, I would:
|
||||
- Implement a dedicated `RequestBuilder` to make the networking layer more flexible and extensible.
|
||||
- Enhance the UI by incorporating more modern design elements, animations, and responsiveness.
|
||||
- Expand test coverage to include additional edge cases and integration tests.
|
||||
|
||||
## What do you think is the weakest part of your project?
|
||||
The weakest areas are:
|
||||
- The UI, which is functional but minimal, using standard UIKit elements.
|
||||
- The networking layer, which, while functional, would benefit from additional abstraction (e.g., `RequestBuilder`) to handle authentication or dynamic query parameters more effectively.
|
||||
|
||||
## Did you copy any code or dependencies? Please make sure to attribute them here!
|
||||
I adapted a regular expression-based string extension for phone number formatting from a Stack Overflow post. While the logic was modified for this project, credit is due to the original author for the inspiration.
|
||||
|
||||
## Is there any other information you’d like us to know?
|
||||
When building projects from scratch, I prioritize setting up the API/Service/Model layer and validating it with tests before focusing on the UI. My strengths lie in designing clean, scalable app architectures. While I am proficient in UIKit, I am less experienced with SwiftUI but eager to continue improving in that area.
|
||||
Loading…
Reference in New Issue
Block a user