Compare commits
8 Commits
0895013cdc
...
8efd109a46
| Author | SHA1 | Date | |
|---|---|---|---|
| 8efd109a46 | |||
| 6f7a43d15e | |||
| 069cc8c06a | |||
| c127a6c465 | |||
| f5c1a64e40 | |||
| f07089a1fc | |||
| 0ad993ca6b | |||
| 43a3c456f7 |
@ -301,8 +301,7 @@
|
|||||||
INFOPLIST_FILE = EmployeeDirectory/Info.plist;
|
INFOPLIST_FILE = EmployeeDirectory/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@ -328,8 +327,7 @@
|
|||||||
INFOPLIST_FILE = EmployeeDirectory/Info.plist;
|
INFOPLIST_FILE = EmployeeDirectory/Info.plist;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@ -12,11 +12,9 @@ extension String {
|
|||||||
/// Non-numeric characters are removed, and formatting is applied based on the length of the string.
|
/// Non-numeric characters are removed, and formatting is applied based on the length of the string.
|
||||||
/// - Returns: A formatted phone number as a string.
|
/// - Returns: A formatted phone number as a string.
|
||||||
internal func formatUSNumber() -> String {
|
internal func formatUSNumber() -> String {
|
||||||
// format the number
|
// mask for the phone numver
|
||||||
return format(with: "XXX-XXX-XXXX", phone: self)
|
let mask = "XXX-XXX-XXXX"
|
||||||
}
|
|
||||||
|
|
||||||
internal func format(with mask: String, phone: String) -> String {
|
|
||||||
let numbers = filter { $0.isNumber }
|
let numbers = filter { $0.isNumber }
|
||||||
var result = ""
|
var result = ""
|
||||||
var index = numbers.startIndex // numbers iterator
|
var index = numbers.startIndex // numbers iterator
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import Foundation
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
extension URL {
|
extension URL {
|
||||||
|
|
||||||
|
/// This will has the URL absoluteString into a that is consistent.
|
||||||
internal var uniqueIdentifier: String {
|
internal var uniqueIdentifier: String {
|
||||||
let data = Data(absoluteString.utf8)
|
let data = Data(absoluteString.utf8)
|
||||||
let hash = SHA256.hash(data: data)
|
let hash = SHA256.hash(data: data)
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import Foundation
|
|||||||
|
|
||||||
/// Wrapper JSON Class for the Employees
|
/// Wrapper JSON Class for the Employees
|
||||||
public struct Employees: Codable {
|
public struct Employees: Codable {
|
||||||
|
|
||||||
|
/// Array of Employees
|
||||||
public var employees: [Employee]
|
public var employees: [Employee]
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|||||||
@ -5,6 +5,12 @@
|
|||||||
// Created by Matt Bruce on 1/20/25.
|
// Created by Matt Bruce on 1/20/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
|
||||||
|
/// This will be the interface for the API for Employees
|
||||||
public protocol EmployeeServiceProtocol {
|
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
|
func getEmployees(_ serviceMode: EmployeeServiceMode) async throws -> Employees
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ public enum EmployeeServiceMode: String, CaseIterable {
|
|||||||
case malformed
|
case malformed
|
||||||
case empty
|
case empty
|
||||||
|
|
||||||
|
/// Enpoint in which to grabe employees from.
|
||||||
public var endpoint: String {
|
public var endpoint: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .production:
|
case .production:
|
||||||
@ -26,11 +27,18 @@ public enum EmployeeServiceMode: String, CaseIterable {
|
|||||||
|
|
||||||
/// Service Layer for Employees
|
/// Service Layer for Employees
|
||||||
public class EmployeeService: EmployeeServiceProtocol {
|
public class EmployeeService: EmployeeServiceProtocol {
|
||||||
|
// MARK: - Properties
|
||||||
|
public static let shared = EmployeeService() // Default shared instance
|
||||||
|
|
||||||
|
// MARK: - Initializer
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
/// Service to get Employees
|
// MARK: - Public Methods
|
||||||
/// - Returns: Array of Employee Structs
|
|
||||||
|
/// 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 {
|
public func getEmployees(_ serviceMode: EmployeeServiceMode = .production) async throws -> Employees {
|
||||||
return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self)
|
return try await NetworkService.shared.fetchData(from: serviceMode.endpoint, as: Employees.self)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,49 +8,80 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// A service that handles image caching using memory, disk, and network in priority order.
|
||||||
public class ImageCacheService {
|
public class ImageCacheService {
|
||||||
|
// MARK: - Properties
|
||||||
public static let shared = ImageCacheService() // Default shared instance
|
public static let shared = ImageCacheService() // Default shared instance
|
||||||
|
|
||||||
|
/// Memory cache for storing images in RAM.
|
||||||
private let memoryCache = NSCache<NSString, UIImage>()
|
private let memoryCache = NSCache<NSString, UIImage>()
|
||||||
|
|
||||||
|
/// File manager for handling disk operations.
|
||||||
private let fileManager = FileManager.default
|
private let fileManager = FileManager.default
|
||||||
|
|
||||||
|
/// Directory where cached images are stored on disk.
|
||||||
private let cacheDirectory: URL = {
|
private let cacheDirectory: URL = {
|
||||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// MARK: - Initializer
|
||||||
|
|
||||||
public init() {}
|
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? {
|
public func loadImage(from url: URL) async -> UIImage? {
|
||||||
let uniqueKey = url.uniqueIdentifier
|
let uniqueKey = url.uniqueIdentifier
|
||||||
let cacheKey = uniqueKey as NSString
|
let cacheKey = uniqueKey as NSString
|
||||||
|
|
||||||
|
// Step 1: Check the memory cache
|
||||||
if let cachedImage = memoryCache.object(forKey: cacheKey) {
|
if let cachedImage = memoryCache.object(forKey: cacheKey) {
|
||||||
return cachedImage
|
return cachedImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 2: Check the disk cache
|
||||||
let diskImagePath = cacheDirectory.appendingPathComponent(uniqueKey)
|
let diskImagePath = cacheDirectory.appendingPathComponent(uniqueKey)
|
||||||
if let diskImage = UIImage(contentsOfFile: diskImagePath.path) {
|
if let diskImage = UIImage(contentsOfFile: diskImagePath.path) {
|
||||||
memoryCache.setObject(diskImage, forKey: cacheKey)
|
memoryCache.setObject(diskImage, forKey: cacheKey) // Cache in memory for faster access next time
|
||||||
return diskImage
|
return diskImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 3: Fetch the image from the network
|
||||||
if let networkImage = await fetchFromNetwork(url: url) {
|
if let networkImage = await fetchFromNetwork(url: url) {
|
||||||
memoryCache.setObject(networkImage, forKey: cacheKey)
|
memoryCache.setObject(networkImage, forKey: cacheKey) // Cache in memory
|
||||||
try? saveImageToDisk(image: networkImage, at: diskImagePath)
|
try? saveImageToDisk(image: networkImage, at: diskImagePath) // Cache on disk
|
||||||
return networkImage
|
return networkImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 4: Return nil if all options fail
|
||||||
return nil
|
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? {
|
private func fetchFromNetwork(url: URL) async -> UIImage? {
|
||||||
do {
|
do {
|
||||||
let (data, _) = try await URLSession.shared.data(from: url)
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
return UIImage(data: data)
|
return UIImage(data: data)
|
||||||
} catch {
|
} catch {
|
||||||
|
print("Failed to fetch image from network for URL: \(url). Error: \(error)")
|
||||||
return nil
|
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 {
|
private func saveImageToDisk(image: UIImage, at path: URL) throws {
|
||||||
guard let data = image.pngData() else { return }
|
guard let data = image.pngData() else { return }
|
||||||
try data.write(to: path)
|
try data.write(to: path)
|
||||||
|
|||||||
@ -26,15 +26,22 @@ public enum NetworkServiceError: Error {
|
|||||||
|
|
||||||
public class NetworkService {
|
public class NetworkService {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
public static let shared = NetworkService() // Default shared instance
|
public static let shared = NetworkService() // Default shared instance
|
||||||
|
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Initializer
|
||||||
|
|
||||||
/// Public initializer to allow customization
|
/// Public initializer to allow customization
|
||||||
public init(session: URLSession = .shared) {
|
public init(session: URLSession = .shared) {
|
||||||
self.session = session
|
self.session = session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Fetches data from a URL and decodes it into a generic Decodable type.
|
/// Fetches data from a URL and decodes it into a generic Decodable type.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - endpoint: The url to fetch data from.
|
/// - endpoint: The url to fetch data from.
|
||||||
|
|||||||
@ -9,13 +9,30 @@ import UIKit
|
|||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
class EmployeesViewController: UIViewController {
|
class EmployeesViewController: UIViewController {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
|
||||||
|
/// List for the employees
|
||||||
private let tableView = UITableView()
|
private let tableView = UITableView()
|
||||||
|
|
||||||
|
/// Will only show when fetching data occurs
|
||||||
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
private let activityIndicator = UIActivityIndicatorView(style: .large)
|
||||||
|
|
||||||
|
/// Allows the user to pick between service modes
|
||||||
private let modeSegmentedControl = UISegmentedControl(items: EmployeeServiceMode.allCases.map{ $0.rawValue } )
|
private let modeSegmentedControl = UISegmentedControl(items: EmployeeServiceMode.allCases.map{ $0.rawValue } )
|
||||||
|
|
||||||
|
/// ViewModel in which drives the screen
|
||||||
private let viewModel = EmployeesViewModel()
|
private let viewModel = EmployeesViewModel()
|
||||||
|
|
||||||
|
/// Holds onto the ViewModels Subscribers
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
/// Will show specific state of the viewModel
|
||||||
private var footerView: TableFooterView?
|
private var footerView: TableFooterView?
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
public override func viewDidLoad() {
|
public override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
setupUI()
|
setupUI()
|
||||||
@ -23,6 +40,9 @@ class EmployeesViewController: UIViewController {
|
|||||||
viewModel.fetchEmployees()
|
viewModel.fetchEmployees()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Setup the UI by adding the views to the main view
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
view.backgroundColor = .white
|
view.backgroundColor = .white
|
||||||
|
|
||||||
@ -46,6 +66,7 @@ class EmployeesViewController: UIViewController {
|
|||||||
navigationItem.titleView = modeSegmentedControl
|
navigationItem.titleView = modeSegmentedControl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Using the ViewModel setup combine handlers
|
||||||
private func bindViewModel() {
|
private func bindViewModel() {
|
||||||
viewModel.$employees
|
viewModel.$employees
|
||||||
.receive(on: RunLoop.main)
|
.receive(on: RunLoop.main)
|
||||||
@ -75,6 +96,7 @@ class EmployeesViewController: UIViewController {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Show state in specific use-cases for the EmployeesViewModel
|
||||||
private func updateFooter() {
|
private func updateFooter() {
|
||||||
var message: String? {
|
var message: String? {
|
||||||
guard !viewModel.isLoading else { return nil }
|
guard !viewModel.isLoading else { return nil }
|
||||||
@ -96,11 +118,16 @@ class EmployeesViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark: - Objective-C Methods
|
||||||
extension EmployeesViewController {
|
extension EmployeesViewController {
|
||||||
|
|
||||||
|
/// Fetch the Employees
|
||||||
@objc private func didPullToRefresh() {
|
@objc private func didPullToRefresh() {
|
||||||
viewModel.fetchEmployees()
|
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) {
|
@objc private func onServiceModeChange(_ sender: UISegmentedControl) {
|
||||||
let selectedMode: EmployeeServiceMode
|
let selectedMode: EmployeeServiceMode
|
||||||
switch sender.selectedSegmentIndex {
|
switch sender.selectedSegmentIndex {
|
||||||
@ -113,6 +140,7 @@ extension EmployeesViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark: - UITableViewDataSource
|
||||||
extension EmployeesViewController: UITableViewDataSource {
|
extension EmployeesViewController: UITableViewDataSource {
|
||||||
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
return viewModel.employees.count
|
return viewModel.employees.count
|
||||||
|
|||||||
@ -8,8 +8,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// ViewModel that will be used along with the EmployeeTableViewCell.
|
||||||
@MainActor
|
@MainActor
|
||||||
public class EmployeeCellViewModel: ObservableObject {
|
public class EmployeeCellViewModel: ObservableObject {
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
private let employee: Employee
|
private let employee: Employee
|
||||||
|
|
||||||
public private(set) var uuid: String
|
public private(set) var uuid: String
|
||||||
@ -21,18 +24,21 @@ public class EmployeeCellViewModel: ObservableObject {
|
|||||||
public private(set) var employeeType: String
|
public private(set) var employeeType: String
|
||||||
@Published public private(set) var smallPhoto: UIImage?
|
@Published public private(set) var smallPhoto: UIImage?
|
||||||
|
|
||||||
|
// MARK: - Initializer
|
||||||
|
|
||||||
public init(employee: Employee) {
|
public init(employee: Employee) {
|
||||||
self.employee = employee
|
self.employee = employee
|
||||||
|
|
||||||
// Initialize properties
|
// Initialize properties
|
||||||
self.uuid = employee.uuid.uuidString
|
uuid = employee.uuid.uuidString
|
||||||
self.fullName = employee.fullName
|
fullName = employee.fullName
|
||||||
self.phoneNumber = employee.phoneNumber?.formatUSNumber()
|
phoneNumber = employee.phoneNumber?.formatUSNumber()
|
||||||
self.emailAddress = employee.emailAddress
|
emailAddress = employee.emailAddress
|
||||||
self.biography = employee.biography
|
biography = employee.biography
|
||||||
self.team = employee.team
|
team = employee.team
|
||||||
self.employeeType = employee.employeeType.description
|
employeeType = employee.employeeType.description
|
||||||
|
|
||||||
|
// Fetch the image for the url if it exists
|
||||||
if let endpoint = employee.photoURLSmall {
|
if let endpoint = employee.photoURLSmall {
|
||||||
Task{
|
Task{
|
||||||
if let smallPhotoURL = URL(string: endpoint) {
|
if let smallPhotoURL = URL(string: endpoint) {
|
||||||
|
|||||||
@ -7,18 +7,17 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// ViewModel that will be bound to an Employees model and used
|
||||||
|
/// specifically with the EmployeesViewController.
|
||||||
@MainActor
|
@MainActor
|
||||||
public class EmployeesViewModel: ObservableObject {
|
public class EmployeesViewModel: ObservableObject {
|
||||||
private let service: EmployeeService
|
|
||||||
private var serviceMode: EmployeeServiceMode = .production
|
private var serviceMode: EmployeeServiceMode = .production
|
||||||
|
|
||||||
@Published public private(set) var employees: [Employee] = []
|
@Published public private(set) var employees: [Employee] = []
|
||||||
@Published public private(set) var errorMessage: String? = nil
|
@Published public private(set) var errorMessage: String? = nil
|
||||||
@Published public private(set) var isLoading: Bool = false
|
@Published public private(set) var isLoading: Bool = false
|
||||||
|
|
||||||
public init(service: EmployeeService = EmployeeService()) {
|
public init() {}
|
||||||
self.service = service
|
|
||||||
}
|
|
||||||
|
|
||||||
public func fetchEmployees() {
|
public func fetchEmployees() {
|
||||||
// resetting values out the values before fetching new data
|
// resetting values out the values before fetching new data
|
||||||
@ -28,17 +27,17 @@ public class EmployeesViewModel: ObservableObject {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
// Fetch employees using the async method
|
// Fetch employees using the async method
|
||||||
let wrapper = try await service.getEmployees(serviceMode)
|
let wrapper = try await EmployeeService.shared.getEmployees(serviceMode)
|
||||||
|
|
||||||
// Update published properties
|
// Update published properties
|
||||||
self.employees = wrapper.employees
|
employees = wrapper.employees
|
||||||
self.isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
// Handle errors
|
// Handle errors
|
||||||
self.employees = []
|
employees = []
|
||||||
self.isLoading = false
|
isLoading = false
|
||||||
self.errorMessage = "An unexpected error occurred, please try to refresh"
|
errorMessage = "An unexpected error occurred, please try to refresh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,17 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// This is the Cell used in the EmployeesTableViewController to show
|
||||||
|
/// the properties of an Employee model.
|
||||||
public class EmployeeTableViewCell: UITableViewCell {
|
public class EmployeeTableViewCell: UITableViewCell {
|
||||||
|
|
||||||
|
/// Used in the TableView registration
|
||||||
static let identifier = "EmployeeTableViewCell"
|
static let identifier = "EmployeeTableViewCell"
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
|
||||||
|
/// UI Elements
|
||||||
private let photoImageView = UIImageView()
|
private let photoImageView = UIImageView()
|
||||||
private let nameLabel = UILabel()
|
private let nameLabel = UILabel()
|
||||||
private let emailLabel = UILabel()
|
private let emailLabel = UILabel()
|
||||||
@ -19,7 +27,10 @@ public class EmployeeTableViewCell: UITableViewCell {
|
|||||||
private let bioLabel = UILabel()
|
private let bioLabel = UILabel()
|
||||||
private let stackView = UIStackView()
|
private let stackView = UIStackView()
|
||||||
|
|
||||||
private var cancellable: AnyCancellable?
|
/// Used for grabbing the photo
|
||||||
|
private var smallPhotoSubscriber: AnyCancellable?
|
||||||
|
|
||||||
|
// MARK: - Initializer
|
||||||
|
|
||||||
public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
public override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
@ -30,6 +41,8 @@ public class EmployeeTableViewCell: UITableViewCell {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
// Configure photoImageView
|
// Configure photoImageView
|
||||||
photoImageView.contentMode = .scaleAspectFill
|
photoImageView.contentMode = .scaleAspectFill
|
||||||
@ -93,9 +106,13 @@ public class EmployeeTableViewCell: UITableViewCell {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
|
||||||
|
/// Override for setting back to a default state
|
||||||
public override func prepareForReuse() {
|
public override func prepareForReuse() {
|
||||||
super.prepareForReuse()
|
super.prepareForReuse()
|
||||||
cancellable = nil
|
smallPhotoSubscriber = nil
|
||||||
photoImageView.image = UIImage(systemName: "person.crop.circle")
|
photoImageView.image = UIImage(systemName: "person.crop.circle")
|
||||||
nameLabel.text = nil
|
nameLabel.text = nil
|
||||||
emailLabel.text = nil
|
emailLabel.text = nil
|
||||||
@ -105,10 +122,12 @@ public class EmployeeTableViewCell: UITableViewCell {
|
|||||||
bioLabel.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) {
|
public func configure(with viewModel: EmployeeCellViewModel) {
|
||||||
|
|
||||||
// Bind the image to the photoImageView
|
// Bind the image to the photoImageView
|
||||||
cancellable = viewModel.$smallPhoto
|
smallPhotoSubscriber = viewModel.$smallPhoto
|
||||||
.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
|
||||||
|
|||||||
@ -6,8 +6,11 @@
|
|||||||
//
|
//
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// Meant to be used as a Message for State in a TableView.
|
||||||
public class TableFooterView: UIView {
|
public class TableFooterView: UIView {
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
/// Label used to show the message
|
/// Label used to show the message
|
||||||
private let messageLabel: UILabel = {
|
private let messageLabel: UILabel = {
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
@ -20,6 +23,8 @@ public class TableFooterView: UIView {
|
|||||||
return label
|
return label
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// MARK: - Initializer
|
||||||
|
|
||||||
init(message: String) {
|
init(message: String) {
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
setupUI()
|
setupUI()
|
||||||
@ -30,6 +35,8 @@ public class TableFooterView: UIView {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
/// Setup the UI
|
/// Setup the UI
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
addSubview(messageLabel)
|
addSubview(messageLabel)
|
||||||
@ -41,6 +48,7 @@ public class TableFooterView: UIView {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Updates the Current Message
|
/// Updates the Current Message
|
||||||
/// - Parameter message: message to show
|
/// - Parameter message: message to show
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
import XCTest
|
|
||||||
|
|
||||||
@testable import EmployeeDirectory
|
@testable import EmployeeDirectory
|
||||||
|
|
||||||
@ -14,36 +13,57 @@ struct EmployeeDirectoryTests {
|
|||||||
|
|
||||||
@Test func getEmployeesValid() async throws {
|
@Test func getEmployeesValid() async throws {
|
||||||
do {
|
do {
|
||||||
let wrapper = try await EmployeeService().getEmployees(.production)
|
let wrapper = try await EmployeeService.shared.getEmployees(.production)
|
||||||
#expect(wrapper.employees.count == 11)
|
#expect(wrapper.employees.count == 11)
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Unexpected error: \(error)")
|
#expect(Bool(false), "Unexpected error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func getEmployeesMalformed() async throws {
|
@Test func getEmployeesMalformed() async throws {
|
||||||
do {
|
do {
|
||||||
let wrapper = try await EmployeeService().getEmployees(.malformed)
|
_ = try await EmployeeService.shared.getEmployees(.malformed)
|
||||||
XCTFail("Expected to throw, but got \(wrapper.employees.count) employees")
|
#expect(Bool(false), "Expected invalidResponse error, but no error was thrown")
|
||||||
} catch let error as NetworkServiceError {
|
} catch let error as NetworkServiceError {
|
||||||
switch error {
|
switch error {
|
||||||
case .invalidResponse:
|
case .decodingError(let decodingError):
|
||||||
XCTAssertTrue(true, "Correctly threw invalidResponse error")
|
#expect(Bool(true), "Expected NetworkServiceError.decodingError, but got \(decodingError)")
|
||||||
default:
|
default:
|
||||||
XCTFail("Expected NetworkServiceError.invalidResponse, but got \(error)")
|
#expect(Bool(false), "Expected NetworkServiceError.decodingError, but got \(error)")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Unexpected error: \(error)")
|
#expect(Bool(false), "Unexpected error: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func getEmployeesEmpty() async throws {
|
@Test func getEmployeesEmpty() async throws {
|
||||||
do {
|
do {
|
||||||
let wrapper = try await EmployeeService().getEmployees(.empty)
|
let wrapper = try await EmployeeService.shared.getEmployees(.empty)
|
||||||
#expect(wrapper.employees.count == 0)
|
#expect(wrapper.employees.count == 0)
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Unexpected error: \(error)")
|
#expect(Bool(false), "Unexpected error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user