Compare commits
25 Commits
255356ff19
...
a1214cf38b
| Author | SHA1 | Date | |
|---|---|---|---|
| a1214cf38b | |||
| 887cfd99ea | |||
| 9d3a23e69d | |||
| 6e4ff7a569 | |||
| f3086d35d5 | |||
| e1521c47a8 | |||
| 4f072677a5 | |||
| fa5d16f143 | |||
| 522861e434 | |||
| 09c8a1ef5a | |||
| 4848fb3160 | |||
| a0adea163f | |||
| aced710388 | |||
| 5d8285935f | |||
| f664e81e5b | |||
| 96468b123b | |||
| 53fc099d6e | |||
| 26864403fd | |||
| 532914d357 | |||
| 16bdd05082 | |||
| dc25bd490d | |||
| e4268fd22f | |||
| bfb721bfd9 | |||
| a517ced1cc | |||
| 9bc63d31cd |
16
EmployeeDirectory/Extensions/URL.swift
Normal file
16
EmployeeDirectory/Extensions/URL.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
70
EmployeeDirectory/Models/Employee.swift
Normal file
70
EmployeeDirectory/Models/Employee.swift
Normal 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 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
EmployeeDirectory/Models/Employees.swift
Normal file
16
EmployeeDirectory/Models/Employees.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
10
EmployeeDirectory/Protocols/EmployeeServiceProtocol.swift
Normal file
10
EmployeeDirectory/Protocols/EmployeeServiceProtocol.swift
Normal 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
|
||||||
|
}
|
||||||
37
EmployeeDirectory/Services/EmployeeService.swift
Normal file
37
EmployeeDirectory/Services/EmployeeService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
EmployeeDirectory/Services/ImageCacheService.swift
Normal file
58
EmployeeDirectory/Services/ImageCacheService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,14 +6,124 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
|
||||||
class EmployeesViewController: UIViewController {
|
class EmployeesViewController: UIViewController {
|
||||||
|
private let tableView = UITableView()
|
||||||
override func viewDidLoad() {
|
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()
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
44
EmployeeDirectory/ViewModels/EmployeeCellViewModel.swift
Normal file
44
EmployeeDirectory/ViewModels/EmployeeCellViewModel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
EmployeeDirectory/ViewModels/EmployeesViewModel.swift
Normal file
52
EmployeeDirectory/ViewModels/EmployeesViewModel.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
93
EmployeeDirectory/Views/EmployeeTableViewCell.swift
Normal file
93
EmployeeDirectory/Views/EmployeeTableViewCell.swift
Normal 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
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
51
EmployeeDirectory/Views/TableFooterView.swift
Normal file
51
EmployeeDirectory/Views/TableFooterView.swift
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -6,12 +6,44 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import XCTest
|
||||||
|
|
||||||
@testable import EmployeeDirectory
|
@testable import EmployeeDirectory
|
||||||
|
|
||||||
struct EmployeeDirectoryTests {
|
struct EmployeeDirectoryTests {
|
||||||
|
|
||||||
@Test func example() async throws {
|
@Test func getEmployeesValid() async throws {
|
||||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user