From cd38b1596cb4e8ff493c48dcd966820dc63c0170 Mon Sep 17 00:00:00 2001 From: Vitor Conceicao Date: Mon, 9 Feb 2026 07:26:17 -0300 Subject: [PATCH 1/2] feat: add home initial view --- .../Sources/Coordinator/HomeCoordinator.swift | 14 +- Projects/Home/Sources/Model/Exchange.swift | 61 ++++++ .../Home/Sources/Model/ExchangeDetail.swift | 35 ++++ .../Model/ExchangeDetailResponse.swift | 14 ++ .../Home/Sources/Model/ExchangeResponse.swift | 14 ++ .../Home/Sources/Model/ExchangeSummary.swift | 14 ++ .../Home/Sources/Model/ExchangeUrls.swift | 14 ++ .../Home/Sources/Model/HomeViewState.swift | 17 ++ Projects/Home/Sources/Model/Status.swift | 27 +++ .../Protocols/HomeServiceProtocol.swift | 14 ++ .../HomeViewModelCoordinatorDelegate.swift | 14 ++ .../Protocols/HomeViewModelDelegate.swift | 14 ++ .../Protocols/HomeViewModelProtocol.swift | 19 ++ .../Home/Sources/Service/HomeEndpoint.swift | 40 ++++ .../Home/Sources/Service/HomeService.swift | 54 +++++ .../Sources/View/HomeViewController.swift | 162 +++++++++++++++ .../Sources/ViewModel/HomeViewModel.swift | 110 +++++++++++ .../Doubles/HomeServiceProtocolSpy.swift | 46 +++++ .../HomeViewModelCoordinatorDelegateSpy.swift | 23 +++ .../Doubles/HomeViewModelDelegateSpy.swift | 23 +++ .../Home/Tests/Doubles/HomeViewModelSpy.swift | 37 ++++ .../Doubles/NetworkServiceProtocolSpy.swift | 29 +++ .../Tests/Service/HomeEndpointTests.swift | 57 ++++++ .../Home/Tests/Service/HomeServiceTests.swift | 185 ++++++++++++++++++ Projects/Home/Tests/TestPlaceHolder.swift | 0 .../Tests/View/HomeViewControllerTests.swift | 91 +++++++++ .../Tests/ViewModel/HomeViewModelTests.swift | 172 ++++++++++++++++ 27 files changed, 1299 insertions(+), 1 deletion(-) create mode 100644 Projects/Home/Sources/Model/Exchange.swift create mode 100644 Projects/Home/Sources/Model/ExchangeDetail.swift create mode 100644 Projects/Home/Sources/Model/ExchangeDetailResponse.swift create mode 100644 Projects/Home/Sources/Model/ExchangeResponse.swift create mode 100644 Projects/Home/Sources/Model/ExchangeSummary.swift create mode 100644 Projects/Home/Sources/Model/ExchangeUrls.swift create mode 100644 Projects/Home/Sources/Model/HomeViewState.swift create mode 100644 Projects/Home/Sources/Model/Status.swift create mode 100644 Projects/Home/Sources/Protocols/HomeServiceProtocol.swift create mode 100644 Projects/Home/Sources/Protocols/HomeViewModelCoordinatorDelegate.swift create mode 100644 Projects/Home/Sources/Protocols/HomeViewModelDelegate.swift create mode 100644 Projects/Home/Sources/Protocols/HomeViewModelProtocol.swift create mode 100644 Projects/Home/Sources/Service/HomeEndpoint.swift create mode 100644 Projects/Home/Sources/Service/HomeService.swift create mode 100644 Projects/Home/Sources/View/HomeViewController.swift create mode 100644 Projects/Home/Sources/ViewModel/HomeViewModel.swift create mode 100644 Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift create mode 100644 Projects/Home/Tests/Doubles/HomeViewModelCoordinatorDelegateSpy.swift create mode 100644 Projects/Home/Tests/Doubles/HomeViewModelDelegateSpy.swift create mode 100644 Projects/Home/Tests/Doubles/HomeViewModelSpy.swift create mode 100644 Projects/Home/Tests/Doubles/NetworkServiceProtocolSpy.swift create mode 100644 Projects/Home/Tests/Service/HomeEndpointTests.swift create mode 100644 Projects/Home/Tests/Service/HomeServiceTests.swift delete mode 100644 Projects/Home/Tests/TestPlaceHolder.swift create mode 100644 Projects/Home/Tests/View/HomeViewControllerTests.swift create mode 100644 Projects/Home/Tests/ViewModel/HomeViewModelTests.swift diff --git a/Projects/Home/Sources/Coordinator/HomeCoordinator.swift b/Projects/Home/Sources/Coordinator/HomeCoordinator.swift index 718b789..2206557 100644 --- a/Projects/Home/Sources/Coordinator/HomeCoordinator.swift +++ b/Projects/Home/Sources/Coordinator/HomeCoordinator.swift @@ -6,8 +6,10 @@ // // +import DependencyInjectionInterfaces import HomeInterfaces import NavigationInterfaces +import NetworkingInterfaces import UIKit public final class HomeCoordinator: HomeCoordinating { @@ -22,6 +24,16 @@ public final class HomeCoordinator: HomeCoordinating { } public func start() { - + let resolver = SharedContainer.shared.resolver() + let networkService: NetworkServiceProtocol = resolver.resolve() + let service = HomeService(networkService: networkService) + let viewModel = HomeViewModel(service: service) + viewModel.coordinatorDelegate = self + let homeVC = HomeViewController(viewModel: viewModel) + navigationController.pushViewController(homeVC, animated: false) } } + +extension HomeCoordinator: HomeViewModelCoordinatorDelegate { + func navigateToDetails(of exchange: Exchange) {} +} diff --git a/Projects/Home/Sources/Model/Exchange.swift b/Projects/Home/Sources/Model/Exchange.swift new file mode 100644 index 0000000..bb5e0c9 --- /dev/null +++ b/Projects/Home/Sources/Model/Exchange.swift @@ -0,0 +1,61 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +struct Exchange: Equatable, Identifiable { + let id: Int + let name: String + let description: String? + let logo: String + let spotVolumeUsd: Double? + let makerFee: Double + let takerFee: Double + let dateLaunched: String + let websiteUrl: String? + let twitterUrl: String? + + let isLoadingDetails: Bool + + init(id: Int, + name: String, + description: String? = nil, + logo: String, + spotVolumeUsd: Double? = nil, + makerFee: Double, + takerFee: Double, + dateLaunched: String, + websiteUrl: String? = nil, + twitterUrl: String? = nil) { + self.id = id + self.name = name + self.description = description + self.logo = logo + self.spotVolumeUsd = spotVolumeUsd + self.makerFee = makerFee + self.takerFee = takerFee + self.dateLaunched = dateLaunched + self.websiteUrl = websiteUrl + self.twitterUrl = twitterUrl + self.isLoadingDetails = false + } + + init(summary: ExchangeSummary) { + self.id = summary.id + self.name = summary.name + self.description = nil + self.logo = "" + self.spotVolumeUsd = nil + self.makerFee = 0.0 + self.takerFee = 0.0 + self.dateLaunched = "" + self.websiteUrl = nil + self.twitterUrl = nil + self.isLoadingDetails = true + } +} diff --git a/Projects/Home/Sources/Model/ExchangeDetail.swift b/Projects/Home/Sources/Model/ExchangeDetail.swift new file mode 100644 index 0000000..ec924e2 --- /dev/null +++ b/Projects/Home/Sources/Model/ExchangeDetail.swift @@ -0,0 +1,35 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +struct ExchangeDetail: Equatable { + let id: Int + let name: String + let description: String? + let logo: String + let spotVolumeUsd: Double? + let makerFee: Double + let takerFee: Double + let dateLaunched: String + let urls: ExchangeURLs +} + +extension ExchangeDetail: Codable { + enum CodingKeys: String, CodingKey { + case id + case name + case description + case logo + case spotVolumeUsd = "spot_volume_usd" + case makerFee = "maker_fee" + case takerFee = "taker_fee" + case dateLaunched = "date_launched" + case urls + } +} diff --git a/Projects/Home/Sources/Model/ExchangeDetailResponse.swift b/Projects/Home/Sources/Model/ExchangeDetailResponse.swift new file mode 100644 index 0000000..dd4bd38 --- /dev/null +++ b/Projects/Home/Sources/Model/ExchangeDetailResponse.swift @@ -0,0 +1,14 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +struct ExchangeDetailResponse: Codable { + let status: Status + let data: [String: T] +} diff --git a/Projects/Home/Sources/Model/ExchangeResponse.swift b/Projects/Home/Sources/Model/ExchangeResponse.swift new file mode 100644 index 0000000..318aa80 --- /dev/null +++ b/Projects/Home/Sources/Model/ExchangeResponse.swift @@ -0,0 +1,14 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +struct ExchangeResponse: Codable { + let status: Status + let data: [T] +} diff --git a/Projects/Home/Sources/Model/ExchangeSummary.swift b/Projects/Home/Sources/Model/ExchangeSummary.swift new file mode 100644 index 0000000..57f83e1 --- /dev/null +++ b/Projects/Home/Sources/Model/ExchangeSummary.swift @@ -0,0 +1,14 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +struct ExchangeSummary: Equatable, Codable { + let id: Int + let name: String +} diff --git a/Projects/Home/Sources/Model/ExchangeUrls.swift b/Projects/Home/Sources/Model/ExchangeUrls.swift new file mode 100644 index 0000000..cfe17f9 --- /dev/null +++ b/Projects/Home/Sources/Model/ExchangeUrls.swift @@ -0,0 +1,14 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +struct ExchangeURLs: Equatable, Codable { + let website: [String] + let twitter: [String] +} diff --git a/Projects/Home/Sources/Model/HomeViewState.swift b/Projects/Home/Sources/Model/HomeViewState.swift new file mode 100644 index 0000000..b55f15f --- /dev/null +++ b/Projects/Home/Sources/Model/HomeViewState.swift @@ -0,0 +1,17 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +enum HomeViewState: Equatable { + case loading + case loadingMore + case empty + case loaded + case error(String) +} diff --git a/Projects/Home/Sources/Model/Status.swift b/Projects/Home/Sources/Model/Status.swift new file mode 100644 index 0000000..f848afa --- /dev/null +++ b/Projects/Home/Sources/Model/Status.swift @@ -0,0 +1,27 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +struct Status: Equatable { + let timestamp: String + let errorCode: Int? + let errorMessage: String? + let elapsed: Int + let creditCount: Int +} + +extension Status: Codable { + enum CodingKeys: String, CodingKey { + case timestamp + case errorCode = "error_code" + case errorMessage = "error_message" + case elapsed + case creditCount = "credit_count" + } +} diff --git a/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift b/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift new file mode 100644 index 0000000..00fbce6 --- /dev/null +++ b/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift @@ -0,0 +1,14 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +protocol HomeServiceProtocol: AnyObject, Sendable { + func fetchExchangesList(page: Int, limit: Int) async throws -> [ExchangeSummary] + func fetchDetailsFor(ids: [String]) async throws -> [ExchangeDetail] +} diff --git a/Projects/Home/Sources/Protocols/HomeViewModelCoordinatorDelegate.swift b/Projects/Home/Sources/Protocols/HomeViewModelCoordinatorDelegate.swift new file mode 100644 index 0000000..53df5d6 --- /dev/null +++ b/Projects/Home/Sources/Protocols/HomeViewModelCoordinatorDelegate.swift @@ -0,0 +1,14 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +@MainActor +protocol HomeViewModelCoordinatorDelegate: AnyObject { + func navigateToDetails(of exchange: Exchange) +} diff --git a/Projects/Home/Sources/Protocols/HomeViewModelDelegate.swift b/Projects/Home/Sources/Protocols/HomeViewModelDelegate.swift new file mode 100644 index 0000000..3a53969 --- /dev/null +++ b/Projects/Home/Sources/Protocols/HomeViewModelDelegate.swift @@ -0,0 +1,14 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +@MainActor +protocol HomeViewModelDelegate: AnyObject { + func didUpdateState(_ state: HomeViewState) +} diff --git a/Projects/Home/Sources/Protocols/HomeViewModelProtocol.swift b/Projects/Home/Sources/Protocols/HomeViewModelProtocol.swift new file mode 100644 index 0000000..5d6fe8a --- /dev/null +++ b/Projects/Home/Sources/Protocols/HomeViewModelProtocol.swift @@ -0,0 +1,19 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +@MainActor +protocol HomeViewModelProtocol: AnyObject { + var delegate: HomeViewModelDelegate? { get set } + var numberOfItems: Int { get } + + func loadData() + func item(at index: Int) -> Exchange + func didSelectRow(at index: Int) +} diff --git a/Projects/Home/Sources/Service/HomeEndpoint.swift b/Projects/Home/Sources/Service/HomeEndpoint.swift new file mode 100644 index 0000000..2f1f0ff --- /dev/null +++ b/Projects/Home/Sources/Service/HomeEndpoint.swift @@ -0,0 +1,40 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import NetworkingInterfaces +import Foundation + +enum HomeEndpoint: APIEndpointProtocol { + case fetchItems(page: Int, limit: Int) + case fetchDetail(ids: [String]) + + var baseURL: String { + ProcessInfo.processInfo.environment["CM_API_BASE_URL"] ?? "" + } + + var path: String { + let basePath: String = "/v1/exchange/" + switch self { + case let .fetchItems(page, limit): + return "\(basePath)map?start=\(page)&limit=\(limit)" + case let .fetchDetail(ids): + let idsQuery = ids.joined(separator: ",") + return "\(basePath)info?id=\(idsQuery)" + } + } + + var method: NetworkingInterfaces.HTTPMethod { + .get + } + + var headers: [String: String]? { + [ + "X-CMC_PRO_API_KEY": ProcessInfo.processInfo.environment["CM_API_KEY"] ?? "" + ] + } +} diff --git a/Projects/Home/Sources/Service/HomeService.swift b/Projects/Home/Sources/Service/HomeService.swift new file mode 100644 index 0000000..b07ed9c --- /dev/null +++ b/Projects/Home/Sources/Service/HomeService.swift @@ -0,0 +1,54 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import NetworkingInterfaces + +enum ServiceError: Error, Equatable { + case decodeFail(String?) + case network(Status) +} + +final class HomeService: Sendable { + private nonisolated(unsafe) let networkService: NetworkServiceProtocol + + init(networkService: NetworkServiceProtocol) { + self.networkService = networkService + } + + private func performRequest(endpoint: APIEndpointProtocol) async throws -> T { + let (data, httpUrlResponse) = try await networkService.request(endpoint: endpoint) + + switch httpUrlResponse.statusCode { + case 200...299: + return try decodeResponse(data) + default: + let error: Status = try decodeResponse(data) + throw ServiceError.network(error) + } + } + + func decodeResponse(_ data: Data) throws -> T { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } +} + +extension HomeService: HomeServiceProtocol { + func fetchExchangesList(page: Int, limit: Int) async throws -> [ExchangeSummary] { + let endpoint = HomeEndpoint.fetchItems(page: page, limit: limit) + let response: ExchangeResponse = try await performRequest(endpoint: endpoint) + return response.data + } + + func fetchDetailsFor(ids: [String]) async throws -> [ExchangeDetail] { + let endpoint = HomeEndpoint.fetchDetail(ids: ids) + let response: ExchangeDetailResponse = try await performRequest(endpoint: endpoint) + return Array(response.data.values) + } +} diff --git a/Projects/Home/Sources/View/HomeViewController.swift b/Projects/Home/Sources/View/HomeViewController.swift new file mode 100644 index 0000000..f7b97d3 --- /dev/null +++ b/Projects/Home/Sources/View/HomeViewController.swift @@ -0,0 +1,162 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import UIKit + +class HomeViewController: UIViewController { + + private let viewModel: HomeViewModelProtocol + + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + tableView.isHidden = true + tableView.delegate = self + tableView.dataSource = self + return tableView + }() + + private lazy var loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .large) + indicator.translatesAutoresizingMaskIntoConstraints = false + indicator.hidesWhenStopped = true + return indicator + }() + + private lazy var footerSpinner: UIActivityIndicatorView = { + let spinner = UIActivityIndicatorView(style: .medium) + spinner.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: 44) + spinner.hidesWhenStopped = true + return spinner + }() + + private lazy var messageLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .center + label.numberOfLines = 0 + label.isHidden = true + return label + }() + + init(viewModel: HomeViewModelProtocol) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + title = "Exchanges" + setupLayout() + + tableView.tableFooterView = footerSpinner + + viewModel.delegate = self + viewModel.loadData() + } + + private func setupLayout() { + view.addSubview(tableView) + view.addSubview(loadingIndicator) + view.addSubview(messageLabel) + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + messageLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + messageLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + messageLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + messageLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20) + ]) + } +} + +// MARK: - Delegate (Updates) +extension HomeViewController: HomeViewModelDelegate { + func didUpdateState(_ state: HomeViewState) { + switch state { + case .loading: + loadingIndicator.startAnimating() + tableView.isHidden = true + messageLabel.isHidden = true + + case .loadingMore: + footerSpinner.startAnimating() + + case .loaded: + loadingIndicator.stopAnimating() + footerSpinner.stopAnimating() + messageLabel.isHidden = true + tableView.isHidden = false + tableView.reloadData() + + case .empty: + loadingIndicator.stopAnimating() + messageLabel.text = "No exchanges found.." + messageLabel.isHidden = false + + case .error(let msg): + loadingIndicator.stopAnimating() + footerSpinner.stopAnimating() + if viewModel.numberOfItems == 0 { + messageLabel.text = msg + messageLabel.isHidden = false + } else { + print(msg) + } + } + } +} + +// MARK: - TableView (Pagination & Cells) +extension HomeViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.numberOfItems + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + let item = viewModel.item(at: indexPath.row) + + var content = cell.defaultContentConfiguration() + content.text = item.name + + if item.isLoadingDetails { + content.secondaryText = "Loading..." + content.secondaryTextProperties.color = .systemBlue + } else { + content.secondaryText = "See more" + content.secondaryTextProperties.color = .secondaryLabel + } + + cell.contentConfiguration = content + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + viewModel.didSelectRow(at: indexPath.row) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let threshold = viewModel.numberOfItems - 3 + if indexPath.row >= threshold { + viewModel.loadData() + } + } +} diff --git a/Projects/Home/Sources/ViewModel/HomeViewModel.swift b/Projects/Home/Sources/ViewModel/HomeViewModel.swift new file mode 100644 index 0000000..c237f41 --- /dev/null +++ b/Projects/Home/Sources/ViewModel/HomeViewModel.swift @@ -0,0 +1,110 @@ +import Foundation + +@MainActor +final class HomeViewModel: HomeViewModelProtocol { + weak var delegate: HomeViewModelDelegate? + weak var coordinatorDelegate: HomeViewModelCoordinatorDelegate? + + private let service: HomeServiceProtocol + + private var currentPage = 1 + private var isFetching = false + private var hasMorePages = true + + private(set) var exchanges: [Exchange] = [] + private(set) var state: HomeViewState = .loading { + didSet { delegate?.didUpdateState(state) } + } + + var numberOfItems: Int { exchanges.count } + + init(service: HomeServiceProtocol) { + self.service = service + } + + func loadData() { + guard !isFetching && hasMorePages else { return } + + isFetching = true + + if exchanges.isEmpty { + state = .loading + } else { + state = .loadingMore + } + + Task { + do { + let summaries = try await service.fetchExchangesList(page: currentPage, limit: 20) + + if summaries.isEmpty { + hasMorePages = false + isFetching = false + if exchanges.isEmpty { + state = .empty + } else { + state = .loaded + } + return + } + + let newItems = summaries.map { Exchange(summary: $0) } + self.exchanges.append(contentsOf: newItems) + + state = .loaded + + let ids = summaries.map { "\($0.id)" } + guard !ids.isEmpty else { + hasMorePages = false + isFetching = false + return + } + + let detailsList = try await service.fetchDetailsFor(ids: ids) + + let detailsMap = Dictionary(uniqueKeysWithValues: detailsList.map { ($0.id, $0) }) + + self.exchanges = self.exchanges.map { item in + guard let detail = detailsMap[item.id] else { return item } + + return mapToExchange(item, detail) + } + + currentPage += 1 + isFetching = false + state = .loaded + + } catch { + isFetching = false + if exchanges.isEmpty { + state = .error(error.localizedDescription) + } else { + print("Fail to load page: \(error)") + state = .loaded + } + } + } + } + + func item(at index: Int) -> Exchange { + exchanges[index] + } + + func didSelectRow(at index: Int) { + guard exchanges.indices.contains(index) else { return } + coordinatorDelegate?.navigateToDetails(of: exchanges[index]) + } + + private func mapToExchange(_ item: Exchange, _ detail: ExchangeDetail) -> Exchange { + return Exchange(id: item.id, + name: item.name, + description: detail.description, + logo: detail.logo, + spotVolumeUsd: detail.spotVolumeUsd, + makerFee: detail.makerFee, + takerFee: detail.takerFee, + dateLaunched: detail.dateLaunched, + websiteUrl: detail.urls.website.first, + twitterUrl: detail.urls.twitter.first) + } +} diff --git a/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift b/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift new file mode 100644 index 0000000..c801368 --- /dev/null +++ b/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift @@ -0,0 +1,46 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +@testable import Home + +@MainActor +final class HomeServiceProtocolSpy: HomeServiceProtocol, Sendable { + enum Method: Equatable { + case fetchExchangesList + case fetchDetailsFor + } + + var calledMethods: [Method] = [] + var fetchExchangesListResult: Result<[ExchangeSummary], Error>? + var fetchDetailsForResult: Result<[ExchangeDetail], Error>? + + func fetchExchangesList(page: Int, limit: Int) async throws -> [ExchangeSummary] { + await MainActor.run { + calledMethods.append(.fetchExchangesList) + } + + guard let result = fetchExchangesListResult else { + fatalError("fetchExchangesListResult not set") + } + + return try result.get() + } + + func fetchDetailsFor(ids: [String]) async throws -> [ExchangeDetail] { + await MainActor.run { + calledMethods.append(.fetchDetailsFor) + } + + guard let result = fetchDetailsForResult else { + fatalError("fetchDetailsForResult not set") + } + + return try result.get() + } +} diff --git a/Projects/Home/Tests/Doubles/HomeViewModelCoordinatorDelegateSpy.swift b/Projects/Home/Tests/Doubles/HomeViewModelCoordinatorDelegateSpy.swift new file mode 100644 index 0000000..8ca5226 --- /dev/null +++ b/Projects/Home/Tests/Doubles/HomeViewModelCoordinatorDelegateSpy.swift @@ -0,0 +1,23 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +@testable import Home + +@MainActor +final class HomeViewModelCoordinatorDelegateSpy: HomeViewModelCoordinatorDelegate { + enum Method: Equatable { + case navigateToDetails(Exchange) + } + + var calledMethods: [Method] = [] + + func navigateToDetails(of exchange: Exchange) { + calledMethods.append(.navigateToDetails(exchange)) + } +} diff --git a/Projects/Home/Tests/Doubles/HomeViewModelDelegateSpy.swift b/Projects/Home/Tests/Doubles/HomeViewModelDelegateSpy.swift new file mode 100644 index 0000000..d04ce3c --- /dev/null +++ b/Projects/Home/Tests/Doubles/HomeViewModelDelegateSpy.swift @@ -0,0 +1,23 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +@testable import Home + +@MainActor +final class HomeViewModelDelegateSpy: HomeViewModelDelegate { + enum Method: Equatable { + case didUpdateState(HomeViewState) + } + + var calledMethods: [Method] = [] + + func didUpdateState(_ state: HomeViewState) { + calledMethods.append(.didUpdateState(state)) + } +} diff --git a/Projects/Home/Tests/Doubles/HomeViewModelSpy.swift b/Projects/Home/Tests/Doubles/HomeViewModelSpy.swift new file mode 100644 index 0000000..28f4feb --- /dev/null +++ b/Projects/Home/Tests/Doubles/HomeViewModelSpy.swift @@ -0,0 +1,37 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +@testable import Home + +@MainActor +final class HomeViewModelSpy: HomeViewModelProtocol { + enum Method: Equatable { + case loadData + case item + case didSelectRow + } + + var calledMethods: [Method] = [] + var delegate: HomeViewModelDelegate? + var numberOfItems: Int = 0 + var itemsToReturn: [Exchange] = [] + + func loadData() { + calledMethods.append(.loadData) + } + + func item(at index: Int) -> Exchange { + calledMethods.append(.item) + return itemsToReturn[index] + } + + func didSelectRow(at index: Int) { + calledMethods.append(.didSelectRow) + } +} diff --git a/Projects/Home/Tests/Doubles/NetworkServiceProtocolSpy.swift b/Projects/Home/Tests/Doubles/NetworkServiceProtocolSpy.swift new file mode 100644 index 0000000..da4fd58 --- /dev/null +++ b/Projects/Home/Tests/Doubles/NetworkServiceProtocolSpy.swift @@ -0,0 +1,29 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import NetworkingInterfaces + +final class NetworkServiceProtocolSpy: NetworkServiceProtocol, @unchecked Sendable { + enum Method: Equatable { + case request + } + + var calledMethods: [Method] = [] + var requestResult: Result<(Data, HTTPURLResponse), Error>? + + func request(endpoint: APIEndpointProtocol) async throws -> (Data, HTTPURLResponse) { + calledMethods.append(.request) + + guard let result = requestResult else { + fatalError("requestResult not set") + } + + return try result.get() + } +} diff --git a/Projects/Home/Tests/Service/HomeEndpointTests.swift b/Projects/Home/Tests/Service/HomeEndpointTests.swift new file mode 100644 index 0000000..940af01 --- /dev/null +++ b/Projects/Home/Tests/Service/HomeEndpointTests.swift @@ -0,0 +1,57 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Testing +import NetworkingInterfaces +@testable import Home + +@Suite +struct HomeEndpointTests { + @Test("GIVEN fetchItems endpoint WHEN accessing path THEN returns correct path with parameters") + func testFetchItemsPath() throws { + let endpoint = HomeEndpoint.fetchItems(page: 1, limit: 10) + + #expect(endpoint.path == "/v1/exchange/map?start=1&limit=10") + } + + @Test("GIVEN fetchDetail endpoint WHEN accessing path THEN returns correct path with comma-separated IDs") + func testFetchDetailPath() throws { + let ids = ["1", "2", "3"] + + let endpoint = HomeEndpoint.fetchDetail(ids: ids) + + #expect(endpoint.path == "/v1/exchange/info?id=1,2,3") + } + + @Test("GIVEN fetchItems endpoint WHEN accessing method THEN returns GET method") + func testFetchItemsMethod() throws { + let endpoint = HomeEndpoint.fetchItems(page: 1, limit: 20) + + let method = endpoint.method + + #expect(method == .get) + } + + @Test("GIVEN fetchDetail endpoint WHEN accessing method THEN returns GET method") + func testFetchDetailMethod() throws { + let endpoint = HomeEndpoint.fetchDetail(ids: ["1"]) + + let method = endpoint.method + + #expect(method == .get) + } + + @Test("GIVEN any endpoint WHEN accessing headers THEN contains API key header") + func testEndpointHeaders() throws { + let endpoint = HomeEndpoint.fetchItems(page: 1, limit: 20) + + let headers = endpoint.headers + + #expect(headers?["X-CMC_PRO_API_KEY"] != nil) + } +} diff --git a/Projects/Home/Tests/Service/HomeServiceTests.swift b/Projects/Home/Tests/Service/HomeServiceTests.swift new file mode 100644 index 0000000..a809a58 --- /dev/null +++ b/Projects/Home/Tests/Service/HomeServiceTests.swift @@ -0,0 +1,185 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Testing +import Foundation +@testable import Home + +@Suite +struct HomeServiceTests { + @Test("GIVEN successful response WHEN fetchExchangesList is called THEN returns exchange summaries") + func testFetchExchangesListSuccess() async throws { + let (sut, networkSpy) = makeSut() + let mockResponse = """ + { + "status": { + "timestamp": "2024-01-01T00:00:00.000Z", + "error_code": null, + "error_message": null, + "elapsed": 10, + "credit_count": 1 + }, + "data": [ + {"id": 1, "name": "Binance"}, + {"id": 2, "name": "Coinbase"} + ] + } + """ + let data = mockResponse.data(using: .utf8)! + let httpResponse = HTTPURLResponse( + url: URL(string: "https://api.example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + + networkSpy.requestResult = .success((data, httpResponse)) + + let result = try await sut.fetchExchangesList(page: 1, limit: 20) + + #expect(networkSpy.calledMethods == [.request]) + #expect(result.count == 2) + #expect(result[0].id == 1) + #expect(result[0].name == "Binance") + #expect(result[1].id == 2) + #expect(result[1].name == "Coinbase") + } + + @Test("GIVEN error response WHEN fetchExchangesList is called THEN throws network error") + func testFetchExchangesListNetworkError() async throws { + let (sut, networkSpy) = makeSut() + let expectedError = ServiceError.network(Status( + timestamp: "2024-01-01T00:00:00.000Z", + errorCode: 401, + errorMessage: "Invalid API Key", + elapsed: 10, + creditCount: 1 + )) + let mockResponse = """ + { + "timestamp": "2024-01-01T00:00:00.000Z", + "error_code": 401, + "error_message": "Invalid API Key", + "elapsed": 10, + "credit_count": 1 + } + """ + let data = mockResponse.data(using: .utf8)! + let httpResponse = HTTPURLResponse( + url: URL(string: "https://api.example.com")!, + statusCode: 401, + httpVersion: nil, + headerFields: nil + )! + + networkSpy.requestResult = .success((data, httpResponse)) + + await #expect(throws: expectedError) { + _ = try await sut.fetchDetailsFor(ids: ["1"]) + } + #expect(networkSpy.calledMethods == [.request]) + } + + @Test("GIVEN successful response WHEN fetchDetailsFor is called THEN returns exchange details") + func testFetchDetailsForSuccess() async throws { + let (sut, networkSpy) = makeSut() + let mockResponse = """ + { + "status": { + "timestamp": "2024-01-01T00:00:00.000Z", + "error_code": null, + "error_message": null, + "elapsed": 10, + "credit_count": 1 + }, + "data": { + "1": { + "id": 1, + "name": "Binance", + "description": "Binance Exchange", + "logo": "https://logo.url", + "spot_volume_usd": 1000000.0, + "maker_fee": 0.001, + "taker_fee": 0.002, + "date_launched": "2017-07-14", + "urls": { + "website": ["https://binance.com"], + "twitter": ["https://twitter.com/binance"] + } + } + } + } + """ + let data = mockResponse.data(using: .utf8)! + let httpResponse = HTTPURLResponse( + url: URL(string: "https://api.example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + + networkSpy.requestResult = .success((data, httpResponse)) + + let result = try await sut.fetchDetailsFor(ids: ["1"]) + + #expect(networkSpy.calledMethods == [.request]) + #expect(result.count == 1) + #expect(result[0].id == 1) + #expect(result[0].name == "Binance") + #expect(result[0].description == "Binance Exchange") + } + + @Test("GIVEN error response WHEN fetchDetailsFor is called THEN throws network error") + func testFetchDetailsForNetworkError() async throws { + let (sut, networkSpy) = makeSut() + let expectedError = ServiceError.network(Status( + timestamp: "2024-01-01T00:00:00.000Z", + errorCode: 500, + errorMessage: "Internal Server Error", + elapsed: 10, + creditCount: 1 + )) + + let mockResponse = """ + { + "timestamp": "2024-01-01T00:00:00.000Z", + "error_code": 500, + "error_message": "Internal Server Error", + "elapsed": 10, + "credit_count": 1 + } + """ + let data = mockResponse.data(using: .utf8)! + let httpResponse = HTTPURLResponse( + url: URL(string: "https://api.example.com")!, + statusCode: 500, + httpVersion: nil, + headerFields: nil + )! + + networkSpy.requestResult = .success((data, httpResponse)) + + await #expect(throws: expectedError) { + _ = try await sut.fetchDetailsFor(ids: ["1"]) + } + #expect(networkSpy.calledMethods == [.request]) + } +} + +extension HomeServiceTests { + typealias SutAndDoubles = ( + sut: HomeService, + networkSpy: NetworkServiceProtocolSpy + ) + + func makeSut() -> SutAndDoubles { + let networkSpy = NetworkServiceProtocolSpy() + let sut = HomeService(networkService: networkSpy) + return (sut, networkSpy) + } +} diff --git a/Projects/Home/Tests/TestPlaceHolder.swift b/Projects/Home/Tests/TestPlaceHolder.swift deleted file mode 100644 index e69de29..0000000 diff --git a/Projects/Home/Tests/View/HomeViewControllerTests.swift b/Projects/Home/Tests/View/HomeViewControllerTests.swift new file mode 100644 index 0000000..27f39ce --- /dev/null +++ b/Projects/Home/Tests/View/HomeViewControllerTests.swift @@ -0,0 +1,91 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Testing +import UIKit +@testable import Home + +@MainActor +@Suite +struct HomeViewControllerTests { + @Test("GIVEN viewController WHEN viewDidLoad is called THEN sets up view and calls loadData") + func testViewDidLoad() throws { + let (sut, viewModelSpy) = makeSut() + + sut.loadView() + sut.viewDidLoad() + + #expect(sut.title == "Exchanges") + #expect(viewModelSpy.calledMethods.contains(.loadData)) + #expect(viewModelSpy.delegate != nil) + } + + @Test("GIVEN viewController WHEN tableView numberOfRows is called THEN returns viewModel numberOfItems") + func testTableViewNumberOfRows() throws { + let (sut, viewModelSpy) = makeSut() + viewModelSpy.numberOfItems = 5 + sut.loadView() + sut.viewDidLoad() + + let numberOfRows = sut.tableView(UITableView(), numberOfRowsInSection: 0) + + #expect(numberOfRows == 5) + } + + @Test("GIVEN viewController WHEN tableView cellForRow is called THEN returns configured cell") + func testTableViewCellForRow() throws { + let (sut, viewModelSpy) = makeSut() + let exchange = Exchange( + id: 1, + name: "Binance", + logo: "logo.url", + makerFee: 0.001, + takerFee: 0.002, + dateLaunched: "2017-07-14" + ) + viewModelSpy.itemsToReturn = [exchange] + viewModelSpy.numberOfItems = 1 + + sut.loadView() + sut.viewDidLoad() + + let tableView = UITableView() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + + let cell = sut.tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0)) + + #expect(viewModelSpy.calledMethods.contains(.item)) + #expect(cell.textLabel?.text == nil || cell.contentConfiguration != nil) + } + + @Test("GIVEN viewController WHEN tableView didSelectRow is called THEN calls viewModel didSelectRow") + func testTableViewDidSelectRow() throws { + let (sut, viewModelSpy) = makeSut() + sut.loadView() + sut.viewDidLoad() + + let tableView = UITableView() + + sut.tableView(tableView, didSelectRowAt: IndexPath(row: 0, section: 0)) + + #expect(viewModelSpy.calledMethods.contains(.didSelectRow)) + } +} + +extension HomeViewControllerTests { + typealias SutAndDoubles = ( + sut: HomeViewController, + viewModelSpy: HomeViewModelSpy + ) + + func makeSut() -> SutAndDoubles { + let viewModelSpy = HomeViewModelSpy() + let sut = HomeViewController(viewModel: viewModelSpy) + return (sut, viewModelSpy) + } +} diff --git a/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift b/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift new file mode 100644 index 0000000..ff84313 --- /dev/null +++ b/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift @@ -0,0 +1,172 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Testing +import Foundation +@testable import Home + +@MainActor +@Suite +struct HomeViewModelTests { + + @Test("GIVEN empty state WHEN loadData is called THEN fetches exchanges and updates state to empty") + func testLoadDataSuccess() async throws { + let (sut, doubles) = makeSut() + doubles.serviceSpy.fetchExchangesListResult = .success([]) + + sut.loadData() + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(doubles.serviceSpy.calledMethods == [.fetchExchangesList]) + #expect(sut.numberOfItems == 0) + #expect(doubles.delegateSpy.calledMethods == [.didUpdateState(.loading), + .didUpdateState(.empty)]) + } + + @Test("GIVEN empty state WHEN loadData fails THEN updates state to error") + func testLoadDataError() async throws { + let (sut, doubles) = makeSut() + let error = NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "Network error"]) + doubles.serviceSpy.fetchExchangesListResult = .failure(error) + + sut.loadData() + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(doubles.serviceSpy.calledMethods.contains(.fetchExchangesList)) + #expect(sut.numberOfItems == 0) + #expect(doubles.delegateSpy.calledMethods == [.didUpdateState(.loading), + .didUpdateState(.error("Network error"))]) + } + + @Test("GIVEN empty state WHEN loadData returns empty array THEN updates state to empty") + func testLoadDataEmpty() async throws { + let (sut, doubles) = makeSut() + doubles.serviceSpy.fetchExchangesListResult = .success([]) + + sut.loadData() + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(doubles.serviceSpy.calledMethods == [.fetchExchangesList]) + #expect(sut.numberOfItems == 0) + #expect(doubles.delegateSpy.calledMethods == [.didUpdateState(.loading), + .didUpdateState(.empty)]) + } + + @Test("GIVEN valid index WHEN didSelectRow is called THEN notifies coordinator delegate") + func testDidSelectRow() async throws { + let (sut, doubles) = makeSut() + + let summaries = [ExchangeSummary(id: 1, name: "Binance")] + let detail = ExchangeDetail( + id: 1, + name: "Binance", + description: "Binance Exchange", + logo: "https://logo.url", + spotVolumeUsd: 1000000.0, + makerFee: 0.001, + takerFee: 0.002, + dateLaunched: "2017-07-14", + urls: ExchangeURLs(website: ["https://binance.com"], twitter: ["@binance"]) + ) + let exchange = Exchange(id: 1, + name: "Binance", + description: "Binance Exchange", + logo: "https://logo.url", + spotVolumeUsd: 1000000.0, + makerFee: 0.001, + takerFee: 0.002, + dateLaunched: "2017-07-14", + websiteUrl: "https://binance.com", + twitterUrl: "@binance" + ) + + doubles.serviceSpy.fetchExchangesListResult = .success(summaries) + doubles.serviceSpy.fetchDetailsForResult = .success([detail]) + + sut.loadData() + try await Task.sleep(nanoseconds: 100_000_000) + + sut.didSelectRow(at: 0) + + #expect(doubles.coordinatorDelegateSpy.calledMethods == [.navigateToDetails(exchange)]) + } + + @Test("GIVEN loaded exchanges WHEN item is requested at valid index THEN returns correct exchange") + func testItemAtIndex() async throws { + let (sut, doubles) = makeSut() + + let summaries = [ + ExchangeSummary(id: 1, name: "Binance"), + ExchangeSummary(id: 2, name: "Coinbase") + ] + let details = [ + ExchangeDetail( + id: 1, + name: "Binance", + description: "Binance Exchange", + logo: "https://logo1.url", + spotVolumeUsd: 1000000.0, + makerFee: 0.001, + takerFee: 0.002, + dateLaunched: "2017-07-14", + urls: ExchangeURLs(website: ["https://binance.com"], twitter: ["@binance"]) + ), + ExchangeDetail( + id: 2, + name: "Coinbase", + description: "Coinbase Exchange", + logo: "https://logo2.url", + spotVolumeUsd: 2000000.0, + makerFee: 0.003, + takerFee: 0.004, + dateLaunched: "2012-06-01", + urls: ExchangeURLs(website: ["https://coinbase.com"], twitter: ["@coinbase"]) + ) + ] + + doubles.serviceSpy.fetchExchangesListResult = .success(summaries) + doubles.serviceSpy.fetchDetailsForResult = .success(details) + + sut.loadData() + try await Task.sleep(nanoseconds: 100_000_000) + + let firstItem = sut.item(at: 0) + let secondItem = sut.item(at: 1) + + #expect(firstItem.id == 1) + #expect(firstItem.name == "Binance") + #expect(secondItem.id == 2) + #expect(secondItem.name == "Coinbase") + } +} + +extension HomeViewModelTests { + typealias SutAndDoubles = ( + sut: HomeViewModel, + doubles: ( + serviceSpy: HomeServiceProtocolSpy, + delegateSpy: HomeViewModelDelegateSpy, + coordinatorDelegateSpy: HomeViewModelCoordinatorDelegateSpy + ) + ) + + func makeSut() -> SutAndDoubles { + let serviceSpy = HomeServiceProtocolSpy() + let delegateSpy = HomeViewModelDelegateSpy() + let coordinatorDelegateSpy = HomeViewModelCoordinatorDelegateSpy() + + let sut = HomeViewModel(service: serviceSpy) + sut.delegate = delegateSpy + sut.coordinatorDelegate = coordinatorDelegateSpy + + return (sut, (serviceSpy, delegateSpy, coordinatorDelegateSpy)) + } +} From 04d1aaca62adc1482905fb0e39dc5e6f1b441b67 Mon Sep 17 00:00:00 2001 From: Vitor Conceicao Date: Mon, 9 Feb 2026 07:26:36 -0300 Subject: [PATCH 2/2] feat: add initial launch screen --- .../Base.lproj/LaunchScreen.storyboard | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/Projects/App/Resources/Base.lproj/LaunchScreen.storyboard b/Projects/App/Resources/Base.lproj/LaunchScreen.storyboard index 865e932..fb0e9a4 100644 --- a/Projects/App/Resources/Base.lproj/LaunchScreen.storyboard +++ b/Projects/App/Resources/Base.lproj/LaunchScreen.storyboard @@ -1,8 +1,11 @@ - - + + + - + + + @@ -11,10 +14,22 @@ - + - + + + + + + + + @@ -22,4 +37,9 @@ + + + + +