diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..796b001 Binary files /dev/null and b/.DS_Store differ diff --git a/Interview.xcodeproj/project.pbxproj b/Interview.xcodeproj/project.pbxproj index 69673c2..f8563ca 100644 --- a/Interview.xcodeproj/project.pbxproj +++ b/Interview.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 2BBDF6DB2E3EFC6400035A61 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BBDF6DA2E3EFC6400035A61 /* Strings.swift */; }; + 2BBDF6DD2E3EFC8800035A61 /* Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BBDF6DC2E3EFC8800035A61 /* Errors.swift */; }; + 2BBDF6DF2E3F037300035A61 /* URLSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BBDF6DE2E3F036300035A61 /* URLSessionProtocol.swift */; }; 6AB24DC023EB546D00530BA5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB24DBF23EB546D00530BA5 /* AppDelegate.swift */; }; 6AB24DC223EB546D00530BA5 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB24DC123EB546D00530BA5 /* SceneDelegate.swift */; }; 6AB24DC923EB546F00530BA5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6AB24DC823EB546F00530BA5 /* Assets.xcassets */; }; @@ -31,6 +34,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 2BBDF6DA2E3EFC6400035A61 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; + 2BBDF6DC2E3EFC8800035A61 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = ""; }; + 2BBDF6DE2E3F036300035A61 /* URLSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = ""; }; 6AB24DBC23EB546D00530BA5 /* Interview.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Interview.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6AB24DBF23EB546D00530BA5 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 6AB24DC123EB546D00530BA5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -66,6 +72,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2BBDF6D92E3EFC4E00035A61 /* Utils */ = { + isa = PBXGroup; + children = ( + 2BBDF6DA2E3EFC6400035A61 /* Strings.swift */, + 2BBDF6DC2E3EFC8800035A61 /* Errors.swift */, + ); + path = Utils; + sourceTree = ""; + }; 6AB24DB323EB546D00530BA5 = { isa = PBXGroup; children = ( @@ -116,6 +131,7 @@ 6AB24DEC23EB550100530BA5 /* ListContacts */ = { isa = PBXGroup; children = ( + 2BBDF6D92E3EFC4E00035A61 /* Utils */, 6AB24DED23EB551000530BA5 /* Models */, 6AB24DF323EB555B00530BA5 /* Services */, 6AB24DF023EB552D00530BA5 /* ViewModels */, @@ -143,6 +159,7 @@ 6AB24DF323EB555B00530BA5 /* Services */ = { isa = PBXGroup; children = ( + 2BBDF6DE2E3F036300035A61 /* URLSessionProtocol.swift */, 6AB24DF423EB556700530BA5 /* ListContactsService.swift */, ); path = Services; @@ -299,12 +316,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2BBDF6DF2E3F037300035A61 /* URLSessionProtocol.swift in Sources */, 6AB24DEF23EB552100530BA5 /* Contact.swift in Sources */, 6AB24DEA23EB54E800530BA5 /* ListContactsViewController.swift in Sources */, + 2BBDF6DB2E3EFC6400035A61 /* Strings.swift in Sources */, 6AB24DF523EB556700530BA5 /* ListContactsService.swift in Sources */, 6AB24DC023EB546D00530BA5 /* AppDelegate.swift in Sources */, 6AB24DF223EB554300530BA5 /* ListContactsViewModel.swift in Sources */, 6AB24DF823EB558D00530BA5 /* ContactCell.swift in Sources */, + 2BBDF6DD2E3EFC8800035A61 /* Errors.swift in Sources */, 6AB24DC223EB546D00530BA5 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -459,13 +479,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = S96TK27X79; + DEVELOPMENT_TEAM = 5Q98FY89DC; INFOPLIST_FILE = Interview/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.picpay.Interview; + PRODUCT_BUNDLE_IDENTIFIER = com.vitex.picpay; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -477,13 +498,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = S96TK27X79; + DEVELOPMENT_TEAM = 5Q98FY89DC; INFOPLIST_FILE = Interview/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.picpay.Interview; + PRODUCT_BUNDLE_IDENTIFIER = com.vitex.picpay; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; diff --git a/Interview/Scenes/ListContacts/Models/Contact.swift b/Interview/Scenes/ListContacts/Models/Contact.swift index 51a2832..14a6730 100644 --- a/Interview/Scenes/ListContacts/Models/Contact.swift +++ b/Interview/Scenes/ListContacts/Models/Contact.swift @@ -11,20 +11,8 @@ import Foundation ] */ -class Contact: Codable { +struct Contact: Codable { var id: Int - var name: String = "" - var photoURL = "" - - init(id: Int, name: String, photoURL: String) { - self.id = id - self.name = name - self.photoURL = photoURL - } - - enum CodingKeys: String, CodingKey { - case name = "name" - case photoURL = "photoURL" - case id = "id" - } + var name: String + var photoURL: String } diff --git a/Interview/Scenes/ListContacts/Services/ListContactsService.swift b/Interview/Scenes/ListContacts/Services/ListContactsService.swift index 04ab326..7d50542 100644 --- a/Interview/Scenes/ListContacts/Services/ListContactsService.swift +++ b/Interview/Scenes/ListContacts/Services/ListContactsService.swift @@ -1,29 +1,30 @@ import Foundation -private let apiURL = "https://669ff1b9b132e2c136ffa741.mockapi.io/picpay/ios/interview/contacts" class ListContactService { - func fetchContacts(completion: @escaping ([Contact]?, Error?) -> Void) { - guard let api = URL(string: apiURL) else { - return + + static let shared = ListContactService() + private let session: URLSessionProtocol + + private init() { + self.session = URLSession.shared + } + init(session: URLSessionProtocol) { + self.session = session + } + + func fetchContacts() async throws -> [Contact] { + guard let api = URL(string: ContactStrings.contactApiURL) else { + throw ContactErrors.invalidURL } - let session = URLSession.shared - let task = session.dataTask(with: api) { (data, response, error) in - guard let jsonData = data else { - return - } - - do { - let decoder = JSONDecoder() - let decoded = try decoder.decode([Contact].self, from: jsonData) - - completion(decoded, nil) - } catch let error { - completion(nil, error) - } + let (data, response) = try await session.data(for: URLRequest(url: api)) + + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + throw ContactErrors.invalidRequest } - task.resume() + return try JSONDecoder().decode([Contact].self, from: data) } } + diff --git a/Interview/Scenes/ListContacts/Services/URLSessionProtocol.swift b/Interview/Scenes/ListContacts/Services/URLSessionProtocol.swift new file mode 100644 index 0000000..83b72d8 --- /dev/null +++ b/Interview/Scenes/ListContacts/Services/URLSessionProtocol.swift @@ -0,0 +1,15 @@ +// +// URLSessionProtocol.swift +// Interview +// +// Created by Vitor Augusto Silva on 02/08/25. +// Copyright © 2025 PicPay. All rights reserved. +// + +import Foundation + +protocol URLSessionProtocol { + func data(for request:URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: URLSessionProtocol {} diff --git a/Interview/Scenes/ListContacts/Utils/Errors.swift b/Interview/Scenes/ListContacts/Utils/Errors.swift new file mode 100644 index 0000000..18bafd2 --- /dev/null +++ b/Interview/Scenes/ListContacts/Utils/Errors.swift @@ -0,0 +1,31 @@ +// +// Errors.swift +// Interview +// +// Created by Vitor Augusto Silva on 02/08/25. +// Copyright © 2025 PicPay. All rights reserved. +// + +import Foundation + +enum ContactErrors: Error { + case invalidURL + case invalidRequest + case decodingFailed + case unknow +} + +extension ContactErrors: LocalizedError { + var errorDescription: String? { + switch self { + case .invalidURL: + return "URL inválida. Por favor informe a URL corretamente" + case .invalidRequest: + return "Não foi possivel realizar essa requisição" + case .decodingFailed: + return "Não foi possivel recuperar os dados" + case .unknow: + return "Erro desconhecido" + } + } +} diff --git a/Interview/Scenes/ListContacts/Utils/Strings.swift b/Interview/Scenes/ListContacts/Utils/Strings.swift new file mode 100644 index 0000000..c95beb8 --- /dev/null +++ b/Interview/Scenes/ListContacts/Utils/Strings.swift @@ -0,0 +1,13 @@ +// +// Strings.swift +// Interview +// +// Created by Vitor Augusto Silva on 02/08/25. +// Copyright © 2025 PicPay. All rights reserved. +// + +import Foundation + +struct ContactStrings { + static let contactApiURL = "https://669ff1b9b132e2c136ffa741.mockapi.io/picpay/ios/interview/contacts" +} diff --git a/Interview/Scenes/ListContacts/ViewModels/ListContactsViewModel.swift b/Interview/Scenes/ListContacts/ViewModels/ListContactsViewModel.swift index e99140d..8dad39e 100644 --- a/Interview/Scenes/ListContacts/ViewModels/ListContactsViewModel.swift +++ b/Interview/Scenes/ListContacts/ViewModels/ListContactsViewModel.swift @@ -1,26 +1,39 @@ import Foundation -class ListContactsViewModel { - private let service = ListContactService() - - private var completion: (([Contact]?, Error?) -> Void)? - - init() { } +enum ViewState { + case loading + case ready + case error(ContactErrors) +} + +struct UserIdsLegacy { + private static let legacyIds: [Int] = [10, 11, 12, 13] - func loadContacts(_ completion: @escaping ([Contact]?, Error?) -> Void) { - self.completion = completion - service.fetchContacts { contacts, err in - self.handle(contacts, err) + static func isLegacy(id: Int) -> Bool { + return legacyIds.contains(id) + } +} + +class ListContactsViewModel { + var onViewStateChange: ((ViewState) -> Void)? + private(set) var contactList: [Contact] = [] + private var viewState: ViewState = .ready { + didSet { + onViewStateChange?(viewState) } } - private func handle(_ contacts: [Contact]?, _ error: Error?) { - if let e = error { - completion?(nil, e) - } - - if let contacts = contacts { - completion?(contacts, nil) + func loadContacts() async { + viewState = .loading + do { + self.contactList = try await ListContactService.shared.fetchContacts() + self.viewState = .ready + }catch { + if let contactError = error as? ContactErrors { + viewState = .error(contactError) + } else { + viewState = .error(.unknow) + } } } } diff --git a/Interview/Scenes/ListContacts/Views/ContactCell.swift b/Interview/Scenes/ListContacts/Views/ContactCell.swift index 31331c4..f23e712 100644 --- a/Interview/Scenes/ListContacts/Views/ContactCell.swift +++ b/Interview/Scenes/ListContacts/Views/ContactCell.swift @@ -4,8 +4,9 @@ class ContactCell: UITableViewCell { lazy var contactImage: UIImageView = { let imgView = UIImageView() imgView.translatesAutoresizingMaskIntoConstraints = false - imgView.contentMode = .scaleAspectFit + imgView.contentMode = .scaleAspectFill imgView.clipsToBounds = true + imgView.backgroundColor = .lightGray return imgView }() @@ -13,25 +14,33 @@ class ContactCell: UITableViewCell { let label = UILabel() label.font = UIFont.systemFont(ofSize: 20, weight: .semibold) label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 1 return label }() + private var imageLoadTask: Task? + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) - configureViews() } required init?(coder: NSCoder) { super.init(coder: coder) - - configureViews() + self.configureViews() } func configureViews() { + self.setupViewHierarchy() + self.setupConstraints() + } + + private func setupViewHierarchy() { contentView.addSubview(contactImage) contentView.addSubview(fullnameLabel) - + } + + private func setupConstraints() { contactImage.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 15).isActive = true contactImage.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true contactImage.heightAnchor.constraint(equalToConstant: 100).isActive = true @@ -42,4 +51,47 @@ class ContactCell: UITableViewCell { fullnameLabel.topAnchor.constraint(equalTo: contentView.topAnchor).isActive = true fullnameLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true } + + func setupCell(with contact: Contact) { + self.fullnameLabel.text = contact.name + self.contactImage.image = nil + self.contactImage.backgroundColor = .lightGray + + guard let url = URL(string: contact.photoURL) else { + self.contactImage.backgroundColor = nil + self.contactImage.image = UIImage(systemName: "exclamationmark.triangle") + return + } + + imageLoadTask?.cancel() + imageLoadTask = Task { [weak self] in + do { + let (data, _) = try await URLSession.shared.data(from: url) + if let image = UIImage(data: data) { + await MainActor.run { + self?.contactImage.backgroundColor = nil + self?.contactImage.image = image + } + } else { + await MainActor.run { + self?.contactImage.backgroundColor = nil + self?.contactImage.image = UIImage(systemName: "exclamationmark.triangle") + } + } + } catch { + await MainActor.run { + self?.contactImage.backgroundColor = nil + self?.contactImage.image = UIImage(systemName: "exclamationmark.triangle") + } + } + } + } + + override func prepareForReuse() { + super.prepareForReuse() + self.imageLoadTask?.cancel() + self.imageLoadTask = nil + self.contactImage.image = nil + self.fullnameLabel.text = nil + } } diff --git a/Interview/Scenes/ListContacts/Views/ListContactsViewController.swift b/Interview/Scenes/ListContacts/Views/ListContactsViewController.swift index d0c6a4f..c518d29 100644 --- a/Interview/Scenes/ListContacts/Views/ListContactsViewController.swift +++ b/Interview/Scenes/ListContacts/Views/ListContactsViewController.swift @@ -1,22 +1,13 @@ import UIKit -class UserIdsLegacy { - static let legacyIds = [10, 11, 12, 13] - - static func isLegacy(id: Int) -> Bool { - return legacyIds.contains(id) - } -} - -class ListContactsViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { - lazy var activity: UIActivityIndicatorView = { - let activity = UIActivityIndicatorView() +class ListContactsViewController: UIViewController { + private lazy var activity: UIActivityIndicatorView = { + let activity = UIActivityIndicatorView(style: .large) activity.hidesWhenStopped = true - activity.startAnimating() return activity }() - lazy var tableView: UITableView = { + private lazy var tableView: UITableView = { let tableView = UITableView(frame: .zero, style: .plain) tableView.translatesAutoresizingMaskIntoConstraints = false tableView.dataSource = self @@ -24,55 +15,68 @@ class ListContactsViewController: UIViewController, UITableViewDataSource, UITab tableView.rowHeight = 120 tableView.register(ContactCell.self, forCellReuseIdentifier: String(describing: ContactCell.self)) tableView.backgroundView = activity - tableView.tableFooterView = UIView() return tableView }() - var contacts = [Contact]() - var viewModel: ListContactsViewModel! - - init() { - super.init(nibName: nil, bundle: nil) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func loadView() { - let view = UIView() - view.backgroundColor = .white - - self.view = view - } + private var viewModel = ListContactsViewModel() + // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() - viewModel = ListContactsViewModel() - configureViews() - navigationController?.title = "Lista de contatos" + self.configureViews() + self.bindingViewModel() + Task { + await viewModel.loadContacts() + } - loadData() } - func configureViews() { - view.backgroundColor = .red - view.addSubview(tableView) - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor) - ]) + self.setupViewHierarchy() + self.setupConstraints() + } + + private func setupViewHierarchy() { + self.view.addSubview(tableView) } - func isLegacy(contact: Contact) -> Bool { - return UserIdsLegacy.isLegacy(id: contact.id) + private func setupConstraints() { + self.tableView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).isActive = true + self.tableView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).isActive = true + self.tableView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true + self.tableView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true } + private func bindingViewModel() { + viewModel.onViewStateChange = { [weak self] state in + DispatchQueue.main.async { + self?.handleViewState(state: state) + } + } + } + + private func handleViewState(state: ViewState) { + switch state { + case .loading: + self.activity.startAnimating() + case .ready: + self.activity.stopAnimating() + self.tableView.reloadData() + case .error(let error): + self.handleError(error) + } + } + + private func handleError(_ error: ContactErrors) { + let alert = UIAlertController(title: "Erro", message: error.errorDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + self.present(alert, animated: true) + } +} + +extension ListContactsViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return contacts.count + return viewModel.contactList.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -80,51 +84,34 @@ class ListContactsViewController: UIViewController, UITableViewDataSource, UITab return UITableViewCell() } - let contact = contacts[indexPath.row] - cell.fullnameLabel.text = contact.name - - if let urlPhoto = URL(string: contact.photoURL) { - do { - let data = try Data(contentsOf: urlPhoto) - let image = UIImage(data: data) - cell.contactImage.image = image - } catch _ {} - } + let contact = viewModel.contactList[indexPath.row] + cell.setupCell(with: contact) return cell } + +} + +extension ListContactsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let contato = contacts[indexPath.row - 1] + tableView.deselectRow(at: indexPath, animated: true) + let selectedContact = viewModel.contactList[indexPath.row] + + var alertMessage = ("","") - guard isLegacy(contact: contato) else { - let alert = UIAlertController(title: "Você tocou em", message: "\(contato.name)", preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - self.present(alert, animated: true) - return + if UserIdsLegacy.isLegacy(id: selectedContact.id) { + alertMessage = ("Atenção","Você tocou no contato sorteado") + } else { + alertMessage = ("Você tocou em",selectedContact.name) } - let alert = UIAlertController(title: "Atenção", message:"Você tocou no contato sorteado", preferredStyle: .alert) + let alert = UIAlertController(title: alertMessage.0, message:alertMessage.1, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self.present(alert, animated: true) + } - - func loadData() { - viewModel.loadContacts { contacts, error in - DispatchQueue.main.async { - if let error = error { - print(error) - - let alert = UIAlertController(title: "Ops, ocorreu um erro", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) - self.present(alert, animated: true) - return - } - - self.contacts = contacts ?? [] - self.tableView.reloadData() - self.activity.stopAnimating() - } - } - } + } + +