Compare commits

...

25 Commits

Author SHA1 Message Date
a1214cf38b forgot a hashvalue always changes so I can't use that, therefore I created extension on URL for a UniqueIdentifier based of crypto that will always be the same.
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 19:28:06 -06:00
887cfd99ea wired up the ViewModel's SmallPhoto property change event with Combine
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 19:20:08 -06:00
9d3a23e69d get the small photo using the ImageCacheService
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 19:19:37 -06:00
6e4ff7a569 first cut at an ImageCache
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 19:19:15 -06:00
f3086d35d5 fixed footview ui bug for not having a frame
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:51:38 -06:00
e1521c47a8 refactored to use new EmployeeCellViewModel
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:49:53 -06:00
4f072677a5 Refactored to use new EmployeeCellViewModel
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:49:32 -06:00
fa5d16f143 created EmployeeCellViewModel
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:48:32 -06:00
522861e434 - refactored to use EmployeeTableViewCell
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:46:36 -06:00
09c8a1ef5a first cut of EmployeeTableViewCell
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:46:23 -06:00
4848fb3160 added pull to refresh and moved out @objc methods to an extension
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:40:52 -06:00
a0adea163f created a TableFooterView to be used and refactored ViewController
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:37:11 -06:00
aced710388 added a segmented control to select service state
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:26:19 -06:00
5d8285935f added CaseIterable
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:26:00 -06:00
f664e81e5b tested empty issue and seems to work well.
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:20:05 -06:00
96468b123b - added a label in the footer to handle state issues like error or empty.
- added a activityIndicator to show/hide based on loading or not.
- testing the malformed state issue

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:19:29 -06:00
53fc099d6e first cut of building out a list of employees using the valid API
this is built off of combine to deal with the state changes of the ViewModel.

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:17:10 -06:00
26864403fd first cut of a view model
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:08:37 -06:00
532914d357 added public initializer
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 18:08:27 -06:00
16bdd05082 wrote test cases for the 3 endpoints based on what they return
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 17:46:36 -06:00
dc25bd490d updated to select endpoint
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 17:39:52 -06:00
e4268fd22f wrote a test to ensure the service works and deserializes 11 employees
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 17:33:02 -06:00
bfb721bfd9 fixed bug in url
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 17:32:30 -06:00
a517ced1cc initial protocol and implementation
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 17:23:07 -06:00
9bc63d31cd created Models for the objects returned from the services
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-20 17:22:50 -06:00
12 changed files with 595 additions and 6 deletions

View File

@ -0,0 +1,16 @@
//
// URL.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
import CryptoKit
extension URL {
internal var uniqueIdentifier: String {
let data = Data(absoluteString.utf8)
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
}

View File

@ -0,0 +1,70 @@
//
// 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 employees small photo. Useful for list view.
public let photoURLSmall: String?
/// The URL of the employees 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"
}
}

View File

@ -0,0 +1,16 @@
//
// Employees.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
/// Wrapper JSON Class for the Employees
public struct Employees: Codable {
public var employees: [Employee]
private enum CodingKeys: String, CodingKey {
case employees
}
}

View File

@ -0,0 +1,10 @@
//
// EmployeeServiceable.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
public protocol EmployeeServiceProtocol {
func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees
}

View File

@ -0,0 +1,37 @@
//
// 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
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 {
public init() {}
/// Service to get Employees
/// - Returns: Array of Employee Structs
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees {
return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self)
}
}

View File

@ -0,0 +1,58 @@
//
// ImageCacheService.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
import UIKit
public class ImageCacheService {
public static let shared = ImageCacheService() // Default shared instance
private let memoryCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default
private let cacheDirectory: URL = {
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
}()
public init() {}
public func loadImage(from url: URL) async -> UIImage? {
let uniqueKey = url.uniqueIdentifier
let cacheKey = uniqueKey as NSString
if let cachedImage = memoryCache.object(forKey: cacheKey) {
return cachedImage
}
let diskImagePath = cacheDirectory.appendingPathComponent(uniqueKey)
if let diskImage = UIImage(contentsOfFile: diskImagePath.path) {
memoryCache.setObject(diskImage, forKey: cacheKey)
return diskImage
}
if let networkImage = await fetchFromNetwork(url: url) {
memoryCache.setObject(networkImage, forKey: cacheKey)
try? saveImageToDisk(image: networkImage, at: diskImagePath)
return networkImage
}
return nil
}
private func fetchFromNetwork(url: URL) async -> UIImage? {
do {
let (data, _) = try await URLSession.shared.data(from: url)
return UIImage(data: data)
} catch {
return nil
}
}
private func saveImageToDisk(image: UIImage, at path: URL) throws {
guard let data = image.pngData() else { return }
try data.write(to: path)
}
}

View File

@ -6,14 +6,124 @@
//
import UIKit
import Combine
class EmployeesViewController: UIViewController {
override func viewDidLoad() {
private let tableView = UITableView()
private let activityIndicator = UIActivityIndicatorView(style: .large)
private let modeSegmentedControl = UISegmentedControl(items: EmployeeServiceMode.allCases.map{ $0.rawValue } )
private let viewModel = EmployeesViewModel()
private var cancellables = Set<AnyCancellable>()
private var footerView: TableFooterView?
public override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
setupUI()
bindViewModel()
viewModel.fetchEmployees()
}
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
}
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)
}
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
}
}
}
extension EmployeesViewController {
@objc private func didPullToRefresh() {
viewModel.fetchEmployees()
}
@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)
}
}
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
}
}

View File

@ -0,0 +1,44 @@
//
// EmployeeCellViewModel.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
import UIKit
@MainActor
public class EmployeeCellViewModel: ObservableObject {
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?
public init(employee: Employee) {
self.employee = employee
// Initialize properties
self.uuid = employee.uuid.uuidString
self.fullName = employee.fullName
self.phoneNumber = employee.phoneNumber
self.emailAddress = employee.emailAddress
self.biography = employee.biography
self.team = employee.team
self.employeeType = employee.employeeType.description
if let endpoint = employee.photoURLSmall {
Task{
if let smallPhotoURL = URL(string: endpoint) {
smallPhoto = await ImageCacheService.shared.loadImage(from: smallPhotoURL)
}
}
}
}
}

View File

@ -0,0 +1,52 @@
//
// EmployeesViewModel.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import Foundation
@MainActor
public class EmployeesViewModel: ObservableObject {
private let service: EmployeeService
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(service: EmployeeService = EmployeeService()) {
self.service = service
}
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 service.getEmployees(serviceMode)
// Update published properties
self.employees = wrapper.employees
self.isLoading = false
} catch {
// Handle errors
self.employees = []
self.isLoading = false
self.errorMessage = "An unexpected error occurred, please try to refresh"
}
}
}
public func changeMode(to mode: EmployeeServiceMode) {
serviceMode = mode
fetchEmployees()
}
}

View File

@ -0,0 +1,93 @@
//
// EmployeeTableViewCell.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import UIKit
import Combine
public class EmployeeTableViewCell: UITableViewCell {
static let identifier = "EmployeeTableViewCell"
private let photoImageView = UIImageView()
private let nameLabel = UILabel()
private let emailLabel = UILabel()
private let stackView = UIStackView()
private var cancellable: AnyCancellable?
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")
}
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
// Configure stackView
stackView.axis = .vertical
stackView.spacing = 5
stackView.translatesAutoresizingMaskIntoConstraints = false
// Add labels to stackView
stackView.addArrangedSubview(nameLabel)
stackView.addArrangedSubview(emailLabel)
// 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)
])
}
public override func prepareForReuse() {
super.prepareForReuse()
photoImageView.image = UIImage(systemName: "person.crop.circle")
nameLabel.text = nil
emailLabel.text = nil
}
public func configure(with viewModel: EmployeeCellViewModel) {
// Bind the image to the photoImageView
cancellable = 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
}
}

View File

@ -0,0 +1,51 @@
//
// EmptyStateFooterView.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 1/20/25.
//
import UIKit
public class TableFooterView: UIView {
/// 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
}()
init(message: String) {
super.init(frame: .zero)
setupUI()
update(message: message)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 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)
])
}
/// Updates the Current Message
/// - Parameter message: message to show
public func update(message: String) {
messageLabel.text = message
}
}

View File

@ -6,12 +6,44 @@
//
import Testing
import XCTest
@testable import EmployeeDirectory
struct EmployeeDirectoryTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
@Test func getEmployeesValid() async throws {
do {
let wrapper = try await EmployeeService().getEmployees(.production)
#expect(wrapper.employees.count == 11)
} catch {
XCTFail("Unexpected error: \(error)")
}
}
@Test func getEmployeesMalformed() async throws {
do {
let wrapper = try await EmployeeService().getEmployees(.malformed)
XCTFail("Expected to throw, but got \(wrapper.employees.count) employees")
} catch let error as NetworkServiceError {
switch error {
case .invalidResponse:
XCTAssertTrue(true, "Correctly threw invalidResponse error")
default:
XCTFail("Expected NetworkServiceError.invalidResponse, but got \(error)")
}
} catch {
XCTFail("Unexpected error: \(error)")
}
}
@Test func getEmployeesEmpty() async throws {
do {
let wrapper = try await EmployeeService().getEmployees(.empty)
#expect(wrapper.employees.count == 0)
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}