Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
30 changes: 26 additions & 4 deletions Interview.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -31,6 +34,9 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
2BBDF6DA2E3EFC6400035A61 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; };
2BBDF6DC2E3EFC8800035A61 /* Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Errors.swift; sourceTree = "<group>"; };
2BBDF6DE2E3F036300035A61 /* URLSessionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionProtocol.swift; sourceTree = "<group>"; };
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 = "<group>"; };
6AB24DC123EB546D00530BA5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -66,6 +72,15 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
2BBDF6D92E3EFC4E00035A61 /* Utils */ = {
isa = PBXGroup;
children = (
2BBDF6DA2E3EFC6400035A61 /* Strings.swift */,
2BBDF6DC2E3EFC8800035A61 /* Errors.swift */,
);
path = Utils;
sourceTree = "<group>";
};
6AB24DB323EB546D00530BA5 = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -116,6 +131,7 @@
6AB24DEC23EB550100530BA5 /* ListContacts */ = {
isa = PBXGroup;
children = (
2BBDF6D92E3EFC4E00035A61 /* Utils */,
6AB24DED23EB551000530BA5 /* Models */,
6AB24DF323EB555B00530BA5 /* Services */,
6AB24DF023EB552D00530BA5 /* ViewModels */,
Expand Down Expand Up @@ -143,6 +159,7 @@
6AB24DF323EB555B00530BA5 /* Services */ = {
isa = PBXGroup;
children = (
2BBDF6DE2E3F036300035A61 /* URLSessionProtocol.swift */,
6AB24DF423EB556700530BA5 /* ListContactsService.swift */,
);
path = Services;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
18 changes: 3 additions & 15 deletions Interview/Scenes/ListContacts/Models/Contact.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
39 changes: 20 additions & 19 deletions Interview/Scenes/ListContacts/Services/ListContactsService.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}

15 changes: 15 additions & 0 deletions Interview/Scenes/ListContacts/Services/URLSessionProtocol.swift
Original file line number Diff line number Diff line change
@@ -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 {}
31 changes: 31 additions & 0 deletions Interview/Scenes/ListContacts/Utils/Errors.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
13 changes: 13 additions & 0 deletions Interview/Scenes/ListContacts/Utils/Strings.swift
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
62 changes: 57 additions & 5 deletions Interview/Scenes/ListContacts/Views/ContactCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,43 @@ 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
}()

lazy var fullnameLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 20, weight: .semibold)
label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 1
return label
}()

private var imageLoadTask: Task<Void, Never>?

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
Expand All @@ -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
}
}
Loading