Compare commits

..

10 Commits

Author SHA1 Message Date
aae4f5b67c fixed issues with stackview stretching + updated biolabel
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-02-06 17:49:50 -06:00
7b65685d1a added push for the details
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-02-06 15:17:57 -06:00
3a9399f076 initial cut for Details VC
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-02-06 15:17:38 -06:00
9102f8d3c6 created details viewmodel
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-02-06 15:17:20 -06:00
c2768a4864 clear cache - refactor to use version numbers
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-02-06 14:02:30 -06:00
4270b609ed updated with Cache for employees
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-02-06 14:02:15 -06:00
0f9515b90b refactored out passing service mode
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 14:12:07 -06:00
0f4cd465b1 fixed a few bugs for refreshControl and footerView
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 14:02:32 -06:00
52d02ea3a1 now using diffable datasource
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

# Conflicts:
#	EmployeeDirectory/ViewControllers/EmployeesViewController.swift

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 13:59:17 -06:00
28bbade08f fixed bug
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
2025-01-21 12:43:07 -06:00
10 changed files with 331 additions and 33 deletions

View File

@ -27,7 +27,7 @@ public enum EmployeeType: String, Codable, CustomStringConvertible {
/// Employee Object /// Employee Object
/// JSON Object defintion /// JSON Object defintion
/// - https://square.github.io/microsite/mobile-interview-project/ /// - https://square.github.io/microsite/mobile-interview-project/
public struct Employee: Codable { public struct Employee: Hashable, Codable {
/// The unique identifier for the employee. Represented as a UUID. /// The unique identifier for the employee. Represented as a UUID.
public let uuid: UUID public let uuid: UUID

View File

@ -10,7 +10,6 @@
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.
/// - Returns: An Employees struct /// - Returns: An Employees struct
func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees func getEmployees() async throws -> Employees
} }

View File

@ -25,6 +25,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let navigationController = UINavigationController(rootViewController: employeeListVC) let navigationController = UINavigationController(rootViewController: employeeListVC)
window.rootViewController = navigationController window.rootViewController = navigationController
let appVersion = 1.5// get app version
let currentVersion = 1.0 //cached version
if appVersion != currentVersion {
EmployeeCacheService.shared.clear()
}
// Set the window to the scene // Set the window to the scene
self.window = window self.window = window
window.makeKeyAndVisible() window.makeKeyAndVisible()

View File

@ -0,0 +1,44 @@
//
// EmployeeCacheService.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 2/6/25.
//
import Foundation
import UIKit
/// A service that handles image caching using memory, disk, and network in priority order.
public class EmployeeCacheService {
// MARK: - Properties
public static let shared = EmployeeCacheService() // Default shared instance
/// Memory cache for storing images in RAM.
private let emplyoees: Employees? = nil
/// 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() {}
public func save(from employees: Employees) throws {
let data = try JSONEncoder().encode(employees)
try data.write(to: cacheDirectory.appendingPathComponent("employees.json"))
}
public func load() throws -> Employees {
let data = try Data(contentsOf: cacheDirectory.appendingPathComponent("employees.json"))
return try JSONDecoder().decode(Employees.self, from: data)
}
public func clear() {
try? FileManager.default.removeItem(at: cacheDirectory.appendingPathComponent("employees.json"))
}
}

View File

@ -7,11 +7,22 @@
import Foundation import Foundation
/// These are the testing URL Endpoints for different states /// These are the testing URL Endpoints for different states
public enum EmployeeServiceMode: String, CaseIterable { internal enum EmployeeServiceMode: String, CaseIterable {
case production case production
case malformed case malformed
case empty case empty
public var service: EmployeeServiceProtocol {
switch self {
case .production:
return EmployeeService.shared
case .malformed:
return EmployeeMalformedService.shared
case .empty:
return EmployeeEmptyService.shared
}
}
/// Enpoint in which to grabe employees from. /// Enpoint in which to grabe employees from.
public var endpoint: String { public var endpoint: String {
switch self { switch self {
@ -25,8 +36,14 @@ public enum EmployeeServiceMode: String, CaseIterable {
} }
} }
/// Service Layer for Employees /// Service Layer for Employees
public class EmployeeService: EmployeeServiceProtocol { public class EmployeeService: EmployeeServiceProtocol {
public enum EmployeeServiceError: Error {
case error
//case LoadError
//case other(Error)
}
// MARK: - Properties // MARK: - Properties
public static let shared = EmployeeService() // Default shared instance public static let shared = EmployeeService() // Default shared instance
@ -39,7 +56,67 @@ public class EmployeeService: 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
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees { public func getEmployees() async throws -> Employees {
return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self) var employees: Employees
let cache = EmployeeCacheService.shared
do {
employees = try cache.load()
return employees
} catch {
if let networkFound = try? await fetchNetworkEmployees() {
employees = networkFound
return employees
}
}
throw EmployeeServiceError.error
}
public func fetchNetworkEmployees() async throws -> Employees {
var employees: Employees
if let networkFound = try? await NetworkService.shared.fetchData(from: EmployeeServiceMode.production.endpoint, as: Employees.self) {
employees = networkFound
try? EmployeeCacheService.shared.save(from: employees)
return employees
} else {
throw EmployeeServiceError.error
}
}
}
/// Service Layer for Employees
public class EmployeeMalformedService: EmployeeServiceProtocol {
// MARK: - Properties
public static let shared = EmployeeMalformedService() // 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() async throws -> Employees {
return try await NetworkService.shared.fetchData(from: EmployeeServiceMode.malformed.endpoint, as: Employees.self)
}
}
/// Service Layer for Employees
public class EmployeeEmptyService: EmployeeServiceProtocol {
// MARK: - Properties
public static let shared = EmployeeEmptyService() // 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() async throws -> Employees {
return try await NetworkService.shared.fetchData(from: EmployeeServiceMode.empty.endpoint, as: Employees.self)
} }
} }

View File

@ -0,0 +1,104 @@
//
// EmployeeDetailViewController.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 2/6/25.
//
import UIKit
import Combine
public class EmployeeDetailViewController: UIViewController {
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 largerPhotoSubscriber: AnyCancellable?
public var viewModel: EmployeeDetailViewModel?
public override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
// Configure Photo
photoImageView.image = UIImage(systemName: "person.crop.circle")
photoImageView.contentMode = .scaleAspectFit
photoImageView.translatesAutoresizingMaskIntoConstraints = false
// Configure StackView
stackView.axis = .vertical
stackView.spacing = 10
stackView.alignment = .leading
stackView.distribution = .fill
stackView.translatesAutoresizingMaskIntoConstraints = false
// Configure Labels
bioLabel.numberOfLines = 0
// Add views to stackView
stackView.addArrangedSubview(photoImageView)
stackView.addArrangedSubview(nameLabel)
stackView.addArrangedSubview(teamLabel)
stackView.addArrangedSubview(employeeTypeLabel)
stackView.addArrangedSubview(phoneLabel)
stackView.addArrangedSubview(emailLabel)
stackView.addArrangedSubview(bioLabel)
view.addSubview(stackView)
photoImageView.heightAnchor.constraint(equalToConstant: 200).isActive = true
photoImageView.widthAnchor.constraint(equalToConstant: 200).isActive = true
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
stackView.bottomAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
stackView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 10).isActive = true
stackView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -10).isActive = true
// Bind the image to the photoImageView
largerPhotoSubscriber = viewModel?.$largePhoto
.compactMap { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak self] image in
self?.photoImageView.image = image
}
if let viewModel {
// 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
}
let phoneTap = UITapGestureRecognizer(target: self, action: #selector(didTapPhoneView(_:)))
phoneLabel.isUserInteractionEnabled = true
phoneLabel.addGestureRecognizer(phoneTap)
}
@objc func didTapPhoneView(_ sender: UITapGestureRecognizer) {
guard let phoneNumber = viewModel?.phoneNumber?.filter({ $0.isNumber }) else {
print("phoneNumber not there")
return
}
let numberUrl = URL(string: "tel://\(phoneNumber)")!
if UIApplication.shared.canOpenURL(numberUrl) {
UIApplication.shared.open(numberUrl)
}
print("call:", phoneNumber)
}
}

View File

@ -31,26 +31,49 @@ 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?
private var dataSource: UITableViewDiffableDataSource<Int, Employee>!
// MARK: - Public Methods // MARK: - Public Methods
public override func viewDidLoad() { public override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
setupUI() setupUI()
setupDataSource()
bindViewModel() bindViewModel()
viewModel.fetchEmployees() viewModel.fetchEmployees()
} }
// MARK: - Private Methods // MARK: - Private Methods
/// Setup TableView dataSource
private func setupDataSource() {
dataSource = UITableViewDiffableDataSource<Int, Employee>(tableView: tableView) { tableView, indexPath, employee in
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmployeeTableViewCell.identifier,for: indexPath) as? EmployeeTableViewCell else {
return UITableViewCell()
}
cell.configure(with: EmployeeCellViewModel(employee: employee))
return cell
}
}
/// Snapshot Handling
private func applySnapshot(employees: [Employee]) {
var snapshot = NSDiffableDataSourceSnapshot<Int, Employee>()
snapshot.appendSections([0])
snapshot.appendItems(employees, toSection: 0)
dataSource.apply(snapshot, animatingDifferences: true)
}
/// Setup the UI by adding the views to the main view /// Setup the UI by adding the views to the main view
private func setupUI() { private func setupUI() {
view.backgroundColor = .white view.backgroundColor = .white
// Configure TableView // Configure TableView
tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier) tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
tableView.dataSource = self
view.addSubview(tableView) view.addSubview(tableView)
tableView.frame = view.bounds tableView.frame = view.bounds
tableView.delegate = self
//add pull to refresh //add pull to refresh
tableView.refreshControl = UIRefreshControl() tableView.refreshControl = UIRefreshControl()
@ -70,10 +93,8 @@ class EmployeesViewController: UIViewController {
private func bindViewModel() { private func bindViewModel() {
viewModel.$employees viewModel.$employees
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.sink { [weak self] _ in .sink { [weak self] employees in
self?.updateFooter() self?.applySnapshot(employees: employees)
self?.tableView.reloadData()
self?.tableView.refreshControl?.endRefreshing()
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -84,6 +105,8 @@ class EmployeesViewController: UIViewController {
self?.activityIndicator.startAnimating() self?.activityIndicator.startAnimating()
} else { } else {
self?.activityIndicator.stopAnimating() self?.activityIndicator.stopAnimating()
self?.tableView.refreshControl?.endRefreshing()
self?.updateFooter()
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -118,6 +141,15 @@ class EmployeesViewController: UIViewController {
} }
} }
extension EmployeesViewController: UITableViewDelegate{
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let employee = viewModel.employees[indexPath.row]
let details = EmployeeDetailViewController()
details.viewModel = .init(employee: employee)
navigationController?.pushViewController(details, animated: true)
}
}
// Mark: - Objective-C Methods // Mark: - Objective-C Methods
extension EmployeesViewController { extension EmployeesViewController {
@ -136,22 +168,6 @@ extension EmployeesViewController {
case 2: selectedMode = .empty case 2: selectedMode = .empty
default: return default: return
} }
viewModel.changeMode(to: selectedMode) viewModel.changeService(to: selectedMode.service)
}
}
/// 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
} }
} }

View File

@ -0,0 +1,50 @@
//
// EmployeeDetailsViewModel.swift
// EmployeeDirectory
//
// Created by Matt Bruce on 2/6/25.
//
import Foundation
import UIKit
/// ViewModel that will be used along with the EmployeeTableViewCell.
@MainActor
public class EmployeeDetailViewModel: 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 largePhoto: 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.photoURLLarge {
Task{
if let photoURL = URL(string: endpoint) {
largePhoto = await ImageCacheService.shared.loadImage(from: photoURL)
}
}
}
}
}

View File

@ -11,7 +11,7 @@ import Foundation
/// specifically with the EmployeesViewController. /// specifically with the EmployeesViewController.
@MainActor @MainActor
public class EmployeesViewModel: ObservableObject { public class EmployeesViewModel: ObservableObject {
private var serviceMode: EmployeeServiceMode = .production private var employeeService: EmployeeServiceProtocol = EmployeeService()
@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
@ -27,7 +27,7 @@ public class EmployeesViewModel: ObservableObject {
Task { Task {
do { do {
// Fetch employees using the async method // Fetch employees using the async method
let wrapper = try await EmployeeService.shared.getEmployees(serviceMode) let wrapper = try await employeeService.getEmployees()
// Update published properties // Update published properties
employees = wrapper.employees employees = wrapper.employees
@ -43,8 +43,8 @@ public class EmployeesViewModel: ObservableObject {
} }
public func changeMode(to mode: EmployeeServiceMode) { public func changeService(to employeeService: EmployeeServiceProtocol) {
serviceMode = mode self.employeeService = employeeService
fetchEmployees() fetchEmployees()
} }
} }

View File

@ -128,6 +128,7 @@ 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 self?.photoImageView.image = image