diff --git a/Projects/Detail/Project.swift b/Projects/Detail/Project.swift index c37b341..dd70811 100644 --- a/Projects/Detail/Project.swift +++ b/Projects/Detail/Project.swift @@ -18,7 +18,15 @@ let interfaceDependecies: [TargetDependency] = [ path: "../Navigation"), ] +let testsDependecies: [TargetDependency] = [ + .project(target: "DependencyInjectionTesting", + path: "../DependencyInjection"), + .project(target: "NavigationTesting", + path: "../Navigation"), +] + let project = Project.templateModule(named: moduleName, targets: [.source, .interfaces, .test, .testing], dependencies: dependecies, + testDependencies: testsDependecies, interfaceDependecies: interfaceDependecies) diff --git a/Projects/Detail/Sources/Coordinator/DetailCoordinator.swift b/Projects/Detail/Sources/Coordinator/DetailCoordinator.swift index e3525b5..91962a5 100644 --- a/Projects/Detail/Sources/Coordinator/DetailCoordinator.swift +++ b/Projects/Detail/Sources/Coordinator/DetailCoordinator.swift @@ -37,6 +37,28 @@ public final class DetailCoordinator: DetailCoordinating { // MARK: - DetailCoordinating public func start() { + let resolver = SharedContainer.shared.resolver() + let networkService: NetworkServiceProtocol = resolver.resolve() + let detailService = DetailService(networkService: networkService) + let viewModel = DetailViewModel(service: detailService) + if let exchange { + viewModel.configure(with: exchange) + } + if let exchangeId { + viewModel.configure(with: exchangeId) + } + viewModel.coordinatorDelegate = self + let viewController = DetailViewController(viewModel: viewModel) + + navigationController.pushViewController(viewController, animated: true) + } +} + +// MARK: - DetailViewModelCoordinatorDelegate + +extension DetailCoordinator: DetailViewModelCoordinatorDelegate { + public func didRequestOpenURL(_ url: URL) { + UIApplication.shared.open(url) } } diff --git a/Projects/Detail/Sources/Model/Asset.swift b/Projects/Detail/Sources/Model/Asset.swift new file mode 100644 index 0000000..e1314a9 --- /dev/null +++ b/Projects/Detail/Sources/Model/Asset.swift @@ -0,0 +1,23 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +struct Asset: Sendable, Equatable { + let id: Int + let name: String + let symbol: String + let priceUsd: Double +} + +extension Asset: Codable { + enum CodingKeys: String, CodingKey { + case id, name, symbol + case priceUsd = "price_usd" + } +} diff --git a/Projects/Detail/Sources/Model/DetailViewState.swift b/Projects/Detail/Sources/Model/DetailViewState.swift new file mode 100644 index 0000000..9d2d3d9 --- /dev/null +++ b/Projects/Detail/Sources/Model/DetailViewState.swift @@ -0,0 +1,18 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +enum DetailViewState: Equatable { + case loading + case loaded(ExchangeDetailModel) + case loadingAssets + case loadedAssets + case error(String) + case errorLoadAssets(title: String, message: String, code: Int) +} diff --git a/Projects/Detail/Sources/Model/ExchangeAssetsResponse.swift b/Projects/Detail/Sources/Model/ExchangeAssetsResponse.swift new file mode 100644 index 0000000..49d0a96 --- /dev/null +++ b/Projects/Detail/Sources/Model/ExchangeAssetsResponse.swift @@ -0,0 +1,41 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces + +struct ExchangeAssetsResponse: Codable, Equatable { + let status: Status + let data: [AssetResponse] +} + +struct AssetResponse: Codable, Equatable { + let walletAddress: String + let balance: Double + let currency: Currency + + enum CodingKeys: String, CodingKey { + case walletAddress = "wallet_address" + case balance + case currency + } +} + +struct Currency: Codable, Equatable { + let cryptoId: Int + let priceUsd: Double + let symbol: String + let name: String + + enum CodingKeys: String, CodingKey { + case cryptoId = "crypto_id" + case priceUsd = "price_usd" + case symbol + case name + } +} diff --git a/Projects/Detail/Sources/Model/ExchangeDetailModel.swift b/Projects/Detail/Sources/Model/ExchangeDetailModel.swift new file mode 100644 index 0000000..75bcd82 --- /dev/null +++ b/Projects/Detail/Sources/Model/ExchangeDetailModel.swift @@ -0,0 +1,40 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces + +struct ExchangeDetailModel: Equatable { + let id: Int + let name: String + let description: String + let logoUrl: String + let spotVolumeUsd: Double? + let makerFee: Double + let takerFee: Double + let dateLaunched: String + let websiteUrl: String? + let twitterUrl: String? +} + +extension ExchangeDetailModel { + init(from exchangeDetail: Exchange) { + self.init( + id: exchangeDetail.id, + name: exchangeDetail.name, + description: exchangeDetail.description ?? "No description available", + logoUrl: exchangeDetail.logo, + spotVolumeUsd: exchangeDetail.spotVolumeUsd, + makerFee: exchangeDetail.makerFee, + takerFee: exchangeDetail.takerFee, + dateLaunched: exchangeDetail.dateLaunched, + websiteUrl: exchangeDetail.websiteUrl, + twitterUrl: exchangeDetail.twitterUrl + ) + } +} diff --git a/Projects/Detail/Sources/Protocols/DetailServiceProtocol.swift b/Projects/Detail/Sources/Protocols/DetailServiceProtocol.swift new file mode 100644 index 0000000..338b427 --- /dev/null +++ b/Projects/Detail/Sources/Protocols/DetailServiceProtocol.swift @@ -0,0 +1,13 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +protocol DetailServiceProtocol: AnyObject, Sendable { + func fetchExchangeAssets(id: Int) async throws -> [Asset] +} diff --git a/Projects/Detail/Sources/Protocols/DetailViewModelCoordinatorDelegate.swift b/Projects/Detail/Sources/Protocols/DetailViewModelCoordinatorDelegate.swift new file mode 100644 index 0000000..58c7615 --- /dev/null +++ b/Projects/Detail/Sources/Protocols/DetailViewModelCoordinatorDelegate.swift @@ -0,0 +1,14 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +@MainActor +protocol DetailViewModelCoordinatorDelegate: AnyObject { + func didRequestOpenURL(_ url: URL) +} diff --git a/Projects/Detail/Sources/Protocols/DetailViewModelDelegate.swift b/Projects/Detail/Sources/Protocols/DetailViewModelDelegate.swift new file mode 100644 index 0000000..5b49094 --- /dev/null +++ b/Projects/Detail/Sources/Protocols/DetailViewModelDelegate.swift @@ -0,0 +1,14 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +@MainActor +protocol DetailViewModelDelegate: AnyObject { + func didUpdateState(_ state: DetailViewState) +} diff --git a/Projects/Detail/Sources/Protocols/DetailViewModelProtocol.swift b/Projects/Detail/Sources/Protocols/DetailViewModelProtocol.swift new file mode 100644 index 0000000..3380ef8 --- /dev/null +++ b/Projects/Detail/Sources/Protocols/DetailViewModelProtocol.swift @@ -0,0 +1,21 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +@MainActor +protocol DetailViewModelProtocol: AnyObject { + var assets: [Asset] { get } + var delegate: DetailViewModelDelegate? { get set } + var state: DetailViewState { get } + + func loadData() + func didTapRetry() + func didTapWebsite() + func didTapTwitter() +} diff --git a/Projects/Detail/Sources/Service/DetailEndpoint.swift b/Projects/Detail/Sources/Service/DetailEndpoint.swift new file mode 100644 index 0000000..39627c8 --- /dev/null +++ b/Projects/Detail/Sources/Service/DetailEndpoint.swift @@ -0,0 +1,35 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import NetworkingInterfaces +import Foundation + +enum DetailEndpoint: APIEndpointProtocol { + case fetchAssets(id: Int) + + var baseURL: String { + ProcessInfo.processInfo.environment["CM_API_BASE_URL"] ?? "" + } + + var path: String { + switch self { + case let .fetchAssets(id): + return "/v1/exchange/assets?id=\(id)" + } + } + + var method: NetworkingInterfaces.HTTPMethod { + .get + } + + var headers: [String: String]? { + [ + "X-CMC_PRO_API_KEY": ProcessInfo.processInfo.environment["CM_API_KEY"] ?? "" + ] + } +} diff --git a/Projects/Detail/Sources/Service/DetailService.swift b/Projects/Detail/Sources/Service/DetailService.swift new file mode 100644 index 0000000..76de45e --- /dev/null +++ b/Projects/Detail/Sources/Service/DetailService.swift @@ -0,0 +1,58 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces +import NetworkingInterfaces + +enum DetailServiceError: Error, Equatable { + case decodeFail(String?) + case network(Status) +} + +final class DetailService: 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 DetailServiceError.network(error) + } + } + + private func decodeResponse(_ data: Data) throws -> T { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } +} + +extension DetailService: DetailServiceProtocol { + func fetchExchangeAssets(id: Int) async throws -> [Asset] { + let endpoint = DetailEndpoint.fetchAssets(id: id) + + let response: ExchangeAssetsResponse = try await performRequest(endpoint: endpoint) + + return response + .data + .map { asset in + Asset(id: asset.currency.cryptoId, + name: asset.currency.name, + symbol: asset.currency.symbol, + priceUsd: asset.currency.priceUsd) + } + } +} diff --git a/Projects/Detail/Sources/View/AssetTableViewCell.swift b/Projects/Detail/Sources/View/AssetTableViewCell.swift new file mode 100644 index 0000000..cf1103b --- /dev/null +++ b/Projects/Detail/Sources/View/AssetTableViewCell.swift @@ -0,0 +1,136 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import UIKit +import DesignSystem + +final class AssetTableViewCell: UITableViewCell { + static let reuseIdentifier = "AssetTableViewCell" + + private lazy var containerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .secondarySystemGroupedBackground + view.layer.cornerRadius = DSSpacings.medium.rawValue + view.clipsToBounds = true + return view + }() + + private lazy var iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.tintColor = .systemGray3 + imageView.image = UIImage(systemName: "bitcoinsign.circle.fill") + return imageView + }() + + private lazy var nameLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 17, weight: .regular) + label.textColor = .label + return label + }() + + private lazy var priceLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 13, weight: .regular) + label.textColor = .systemGreen + return label + }() + + private lazy var textStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [nameLabel, priceLabel]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = DSSpacings.xSmall.rawValue + return stackView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with asset: Asset) { + nameLabel.text = asset.name + priceLabel.text = formatPrice(asset.priceUsd) + } + + private func formatPrice(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencySymbol = "$ " + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 2 + formatter.groupingSeparator = "." + formatter.decimalSeparator = "," + return formatter.string(from: NSNumber(value: value)) ?? "$ 0,00" + } +} + +extension AssetTableViewCell: ViewCode { + func buildViewHierarch() { + contentView.addSubview(containerView) + containerView.addSubview(iconImageView) + containerView.addSubview(textStackView) + } + + func setUpConstraints() { + let horizontalPadding = DSSpacings.large.rawValue + let verticalPadding = DSSpacings.small.rawValue + + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(greaterThanOrEqualTo: contentView.topAnchor, + constant: verticalPadding), + containerView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, + constant: -verticalPadding), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, + constant: horizontalPadding), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, + constant: -horizontalPadding), + containerView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + iconImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: horizontalPadding), + iconImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + iconImageView.widthAnchor.constraint(equalToConstant: 42), + iconImageView.heightAnchor.constraint(equalToConstant: 42), + + textStackView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, + constant: horizontalPadding), + textStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -horizontalPadding), + textStackView.topAnchor.constraint(equalTo: containerView.topAnchor, + constant: DSSpacings.small.rawValue), + textStackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, + constant: -DSSpacings.medium.rawValue) + ]) + } + + func additionalConfiguration() { + selectionStyle = .none + contentView.backgroundColor = .clear + } +} + +#if DEBUG +import SwiftUI + +@available(iOS 17.0, *) +#Preview { + let view = AssetTableViewCell() + view.configure(with: .init(id: 1, name: "opa", symbol: "OPa", priceUsd: 123.4)) + return view +} +#endif diff --git a/Projects/Detail/Sources/View/DetailHeaderView.swift b/Projects/Detail/Sources/View/DetailHeaderView.swift new file mode 100644 index 0000000..98ebb49 --- /dev/null +++ b/Projects/Detail/Sources/View/DetailHeaderView.swift @@ -0,0 +1,236 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import UIKit +import DesignSystem + +final class DetailHeaderView: UIView { + private lazy var containerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .secondarySystemGroupedBackground + view.layer.cornerRadius = DSSpacings.large.rawValue + view.clipsToBounds = true + return view + }() + + private lazy var logoImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFill + imageView.layer.cornerRadius = 11 + imageView.clipsToBounds = true + imageView.tintColor = .systemGray3 + imageView.image = UIImage(systemName: "bahtsign.bank.building.fill") + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 16, weight: .semibold) + label.textColor = .label + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 11, weight: .regular) + label.textColor = .secondaryLabel + label.numberOfLines = 10 + label.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + return label + }() + + private lazy var topSeparator: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemFill + return view + }() + + private lazy var dateLaunchedLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 11, weight: .regular) + label.textColor = .secondaryLabel + return label + }() + + private lazy var makerFeeLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 11, weight: .regular) + label.textColor = .secondaryLabel + label.textAlignment = .left + return label + }() + + private lazy var takerFeeLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 11, weight: .regular) + label.textColor = .secondaryLabel + label.textAlignment = .left + return label + }() + + private lazy var infoStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [dateLaunchedLabel, makerFeeLabel, takerFeeLabel]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = DSSpacings.xSmall.rawValue + return stackView + }() + + private lazy var bottomSeparator: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemFill + return view + }() + + lazy var twitterButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + let config = UIImage.SymbolConfiguration(pointSize: 18) + button.setImage(UIImage(systemName: "bird.fill", withConfiguration: config), for: .normal) + button.tintColor = .white + button.backgroundColor = .systemBlue + button.layer.cornerRadius = 18 + button.clipsToBounds = true + return button + }() + + lazy var websiteButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + let config = UIImage.SymbolConfiguration(pointSize: 18) + button.setImage(UIImage(systemName: "globe", withConfiguration: config), for: .normal) + button.tintColor = .white + button.backgroundColor = .systemBlue + button.layer.cornerRadius = 18 + button.clipsToBounds = true + return button + }() + + private lazy var buttonsStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [twitterButton, websiteButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = DSSpacings.large.rawValue + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(with model: ExchangeDetailModel) { + titleLabel.text = "\(model.name) - ID \(model.id)" + descriptionLabel.text = model.description + dateLaunchedLabel.text = "Date launched: \(model.dateLaunched)" + makerFeeLabel.text = "Maker: \(formatPercentage(model.makerFee))" + takerFeeLabel.text = "Taker: \(formatPercentage(model.takerFee))" + loadImage(from: model.logoUrl) + websiteButton.isHidden = model.websiteUrl == nil + twitterButton.isHidden = model.twitterUrl == nil + } + + private func formatPercentage(_ value: Double) -> String { + String(format: "%.2f%%", value) + } + + private func loadImage(from urlString: String) { + guard let url = URL(string: urlString) else { return } + Task { + do { + let (data, _) = try await URLSession.shared.data(from: url) + if let image = UIImage(data: data) { + await MainActor.run { logoImageView.image = image } + } + } catch {} + } + } +} + +extension DetailHeaderView: ViewCode { + func buildViewHierarch() { + addSubview(containerView) + containerView.addSubview(logoImageView) + containerView.addSubview(titleLabel) + containerView.addSubview(descriptionLabel) + containerView.addSubview(topSeparator) + containerView.addSubview(infoStackView) + containerView.addSubview(bottomSeparator) + containerView.addSubview(buttonsStackView) + } + + func setUpConstraints() { + let padding = DSSpacings.large.rawValue + let innerPadding = DSSpacings.medium.rawValue + + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding), + containerView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + containerView.centerYAnchor.constraint(equalTo: centerYAnchor), + + logoImageView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: padding), + logoImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), + logoImageView.widthAnchor.constraint(equalToConstant: 64), + logoImageView.heightAnchor.constraint(equalToConstant: 64), + + titleLabel.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: innerPadding), + titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding), + + descriptionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, + constant: DSSpacings.small.rawValue), + descriptionLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), + descriptionLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding), + + topSeparator.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, + constant: innerPadding), + topSeparator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, + constant: DSSpacings.xLarge.rawValue), + topSeparator.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, + constant: -DSSpacings.xLarge.rawValue), + topSeparator.heightAnchor.constraint(equalToConstant: 3.0 / UIScreen.main.scale), + + infoStackView.topAnchor.constraint(equalTo: topSeparator.bottomAnchor, constant: innerPadding), + infoStackView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: padding), + infoStackView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -padding), + + bottomSeparator.topAnchor.constraint(equalTo: infoStackView.bottomAnchor, + constant: innerPadding), + bottomSeparator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, + constant: DSSpacings.xLarge.rawValue), + bottomSeparator.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, + constant: -DSSpacings.xLarge.rawValue), + bottomSeparator.heightAnchor.constraint(equalToConstant: 3.0 / UIScreen.main.scale), + + twitterButton.widthAnchor.constraint(equalToConstant: 36), + twitterButton.heightAnchor.constraint(equalToConstant: 36), + websiteButton.widthAnchor.constraint(equalToConstant: 36), + websiteButton.heightAnchor.constraint(equalToConstant: 36), + + buttonsStackView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + buttonsStackView.topAnchor.constraint(equalTo: bottomSeparator.bottomAnchor, + constant: padding), + buttonsStackView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -padding) + ]) + } +} diff --git a/Projects/Detail/Sources/View/DetailViewController.swift b/Projects/Detail/Sources/View/DetailViewController.swift new file mode 100644 index 0000000..885fab8 --- /dev/null +++ b/Projects/Detail/Sources/View/DetailViewController.swift @@ -0,0 +1,211 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import UIKit +import DesignSystem + +@MainActor +final class DetailViewController: UIViewController { + + private enum Section: Int, CaseIterable { + case assets = 0 + } + + private let viewModel: DetailViewModelProtocol + private var detailModel: ExchangeDetailModel? + + private lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.register(AssetTableViewCell.self, forCellReuseIdentifier: AssetTableViewCell.reuseIdentifier) + tableView.delegate = self + tableView.dataSource = self + tableView.separatorStyle = .none + tableView.isHidden = true + return tableView + }() + + private lazy var headerView: DetailHeaderView = { + let view = DetailHeaderView() + return view + }() + + private lazy var loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .large) + indicator.translatesAutoresizingMaskIntoConstraints = false + indicator.hidesWhenStopped = true + return indicator + }() + + private lazy var errorView: ErrorView = { + let view = ErrorView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isHidden = true + return view + }() + + init(viewModel: DetailViewModelProtocol) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + viewModel.delegate = self + viewModel.loadData() + } + + private func updateTableHeaderView() { + headerView.frame.size.width = tableView.bounds.width + + let targetSize = CGSize(width: tableView.bounds.width, height: UIView.layoutFittingCompressedSize.height) + let dynamicSize = headerView.systemLayoutSizeFitting( + targetSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + + if headerView.frame.height != dynamicSize.height { + headerView.frame.size.height = dynamicSize.height + tableView.tableHeaderView = headerView + } + } + + @objc private func didTapWebsiteButton() { + viewModel.didTapWebsite() + } + + @objc private func didTapTwitterButton() { + viewModel.didTapTwitter() + } +} + +extension DetailViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + if case .loadedAssets = viewModel.state, viewModel.assets.isEmpty { + return 0 + } + + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.assets.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: AssetTableViewCell.reuseIdentifier, + for: indexPath + ) as? AssetTableViewCell else { + return UITableViewCell() + } + + cell.configure(with: viewModel.assets[indexPath.row]) + return cell + } +} + +extension DetailViewController: ViewCode { + func buildViewHierarch() { + view.addSubview(tableView) + view.addSubview(loadingIndicator) + view.addSubview(errorView) + } + + func setUpConstraints() { + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + errorView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + errorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + errorView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + errorView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + func additionalConfiguration() { + view.backgroundColor = .systemBackground + + headerView.websiteButton.addTarget(self, action: #selector(didTapWebsiteButton), for: .touchUpInside) + headerView.twitterButton.addTarget(self, action: #selector(didTapTwitterButton), for: .touchUpInside) + errorView.retryAction = { [weak self] in + self?.viewModel.didTapRetry() + } + } +} + +extension DetailViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let headerView = HeaderSectionView() + + headerView.configure(title: "Assets") + headerView.loader.startAnimating() + + if case .loadedAssets = viewModel.state { + headerView.loader.stopAnimating() + headerView.loader.isHidden = true + } + + return headerView + } +} + +extension DetailViewController: DetailViewModelDelegate { + func didUpdateState(_ state: DetailViewState) { + switch state { + case .loading: + tableView.isHidden = true + errorView.isHidden = true + loadingIndicator.startAnimating() + + case .loaded(let model): + loadingIndicator.stopAnimating() + errorView.isHidden = true + tableView.isHidden = false + detailModel = model + title = model.name + headerView.configure(with: model) + updateTableHeaderView() + + case .loadingAssets: + return + case .loadedAssets: + if viewModel.assets.isEmpty { + tableView.reloadData() + return + } + let indexSet = IndexSet(integer: Section.assets.rawValue) + tableView.reloadSections(indexSet, with: .fade) + + case .error(let message): + loadingIndicator.stopAnimating() + tableView.isHidden = true + errorView.isHidden = false + errorView.configure(title: "Error Loading Details", message: message) + + case .errorLoadAssets(let title, let message, _): + errorView.isHidden = false + errorView.configure(title: title, message: message) + errorView.retryAction = { [weak self] in + self?.viewModel.didTapRetry() + } + } + } +} diff --git a/Projects/Detail/Sources/View/HeaderSectionView.swift b/Projects/Detail/Sources/View/HeaderSectionView.swift new file mode 100644 index 0000000..7f80a0d --- /dev/null +++ b/Projects/Detail/Sources/View/HeaderSectionView.swift @@ -0,0 +1,77 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import UIKit +import DesignSystem + +final class HeaderSectionView: UITableViewHeaderFooterView { + // MARK: - UI Components + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 20, weight: .semibold) + label.textColor = .label + return label + }() + + lazy var loader: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.translatesAutoresizingMaskIntoConstraints = false + indicator.hidesWhenStopped = true + return indicator + }() + + private lazy var stackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [titleLabel, loader]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .horizontal + stack.spacing = 8 + stack.alignment = .center + return stack + }() + + // MARK: - Init + + override init(reuseIdentifier: String?) { + super.init(reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configuration + + func configure(title: String) { + titleLabel.text = title + } +} + +// MARK: - ViewCode Extension + +extension HeaderSectionView: ViewCode { + func buildViewHierarch() { + contentView.addSubview(stackView) + } + + func setUpConstraints() { + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, + constant: DSSpacings.large.rawValue), + stackView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, + constant: -DSSpacings.large.rawValue), + stackView.topAnchor.constraint(equalTo: contentView.topAnchor, + constant: DSSpacings.medium.rawValue), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, + constant: -DSSpacings.medium.rawValue) + ]) + } +} diff --git a/Projects/Detail/Sources/ViewModel/DetailViewModel.swift b/Projects/Detail/Sources/ViewModel/DetailViewModel.swift new file mode 100644 index 0000000..7fb2061 --- /dev/null +++ b/Projects/Detail/Sources/ViewModel/DetailViewModel.swift @@ -0,0 +1,79 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces + +@MainActor +final class DetailViewModel: DetailViewModelProtocol { + weak var delegate: DetailViewModelDelegate? + weak var coordinatorDelegate: DetailViewModelCoordinatorDelegate? + + private let service: DetailServiceProtocol + private var exchangeId: Int? + private var exchange: Exchange? + private(set) var assets: [Asset] = [] + private var detail: ExchangeDetailModel? + + private(set) var state: DetailViewState = .loading { + didSet { delegate?.didUpdateState(state) } + } + + init(service: DetailServiceProtocol) { + self.service = service + } + + func configure(with exchangeId: Int) { + self.exchangeId = exchangeId + } + + func configure(with exchange: Exchange) { + self.exchange = exchange + } + + func loadData() { + if let exchange { + let model = ExchangeDetailModel(from: exchange) + self.detail = model + state = .loaded(model) + + Task { + do { + let assets = try await service.fetchExchangeAssets(id: exchange.id) + self.assets = assets + state = .loadedAssets + } catch { + state = .errorLoadAssets( + title: "Failed to load assets", + message: error.localizedDescription, + code: 0 + ) + } + } + return + } + + state = .error("Invalid Exchanges, please try another option") + } + + func didTapRetry() { + loadData() + } + + func didTapWebsite() { + guard let urlString = detail?.websiteUrl, + let url = URL(string: urlString) else { return } + coordinatorDelegate?.didRequestOpenURL(url) + } + + func didTapTwitter() { + guard let urlString = detail?.twitterUrl, + let url = URL(string: urlString) else { return } + coordinatorDelegate?.didRequestOpenURL(url) + } +} diff --git a/Projects/Detail/Tests/Coordinator/DetailCoordinatorTests.swift b/Projects/Detail/Tests/Coordinator/DetailCoordinatorTests.swift new file mode 100644 index 0000000..68ce71e --- /dev/null +++ b/Projects/Detail/Tests/Coordinator/DetailCoordinatorTests.swift @@ -0,0 +1,111 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import DependencyInjectionInterfaces +import DependencyInjectionTesting +import HomeInterfaces +import NavigationInterfaces +import NavigationTesting +import NetworkingInterfaces +import Testing +import UIKit + +@testable import Detail + +@MainActor +@Suite +struct DetailCoordinatorTests { + + // MARK: - Start Tests + + @Test("GIVEN DetailCoordinator with exchange WHEN start is called THEN pushes detail view controller") + func startWithExchangePushesDetailViewController() { + let (sut, doubles) = makeSut(exchange: makeExchange()) + doubles.injectorSpy + .register(NetworkServiceProtocol.self) { _ in + doubles.networkSpy + } + + sut.start() + + #expect(doubles.injectorSpy.calledMethods == [.resolve]) + #expect(doubles.navigationController.calledMethods == [.pushViewController]) + } + + @Test("GIVEN DetailCoordinator with exchangeId WHEN start is called THEN pushes detail view controller") + func startWithExchangeIdPushesDetailViewController() { + let (sut, doubles) = makeSut(exchangeId: 1) + doubles.injectorSpy + .register(NetworkServiceProtocol.self) { _ in + doubles.networkSpy + } + + sut.start() + + #expect(doubles.injectorSpy.calledMethods == [.resolve]) + #expect(doubles.navigationController.calledMethods == [.pushViewController]) + } +} + +extension DetailCoordinatorTests { + typealias SutAndDoubles = ( + sut: DetailCoordinator, + doubles: ( + networkSpy: NetworkServiceProtocolSpy, + coordinatorSpy: CoordinatorSpy, + navigationController: NavigationControllerSpy, + injectorSpy: DependencyInjectorSpy + ) + ) + + func makeSut( + exchange: Exchange? = nil, + exchangeId: Int = 0 + ) -> SutAndDoubles { + + let navigationController = NavigationControllerSpy() + let coordinatorSpy = CoordinatorSpy(navigationController: navigationController) + let networkSpy = NetworkServiceProtocolSpy() + let injectorSpy = DependencyInjectorSpy() + SharedContainer.shared.setInjector(injectorSpy) + + + let sut: DetailCoordinator + + if let exchange = exchange { + sut = DetailCoordinator( + parentCoordinator: coordinatorSpy, + navigationController: navigationController, + exchange: exchange + ) + } else { + sut = DetailCoordinator( + parentCoordinator: coordinatorSpy, + navigationController: navigationController, + exchangeId: exchangeId + ) + } + + return (sut, (networkSpy, coordinatorSpy, navigationController, injectorSpy)) + } + + func makeExchange() -> Exchange { + Exchange( + id: 1, + name: "Binance", + description: "Binance Exchange", + logo: "https://logo.url", + spotVolumeUsd: 1000000.0, + makerFee: 0.1, + takerFee: 0.2, + dateLaunched: "2017-07-14", + websiteUrl: "https://binance.com", + twitterUrl: "https://twitter.com/binance" + ) + } +} diff --git a/Projects/Detail/Tests/Doubles/DetailServiceProtocolSpy.swift b/Projects/Detail/Tests/Doubles/DetailServiceProtocolSpy.swift new file mode 100644 index 0000000..93958c2 --- /dev/null +++ b/Projects/Detail/Tests/Doubles/DetailServiceProtocolSpy.swift @@ -0,0 +1,32 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +@testable import Detail + +@MainActor +final class DetailServiceProtocolSpy: DetailServiceProtocol, Sendable { + enum Method: Equatable { + case fetchExchangeAssets + } + + var calledMethods: [Method] = [] + nonisolated(unsafe) var fetchExchangeAssetsResult: Result<[Asset], Error>? + + nonisolated func fetchExchangeAssets(id: Int) async throws -> [Asset] { + await MainActor.run { + calledMethods.append(.fetchExchangeAssets) + } + + guard let result = fetchExchangeAssetsResult else { + fatalError("fetchExchangeAssetsResult not set") + } + + return try result.get() + } +} diff --git a/Projects/Detail/Tests/Doubles/DetailViewModelSpy.swift b/Projects/Detail/Tests/Doubles/DetailViewModelSpy.swift new file mode 100644 index 0000000..382b6bf --- /dev/null +++ b/Projects/Detail/Tests/Doubles/DetailViewModelSpy.swift @@ -0,0 +1,38 @@ +import Foundation +@testable import Detail + +@MainActor +final class DetailViewModelSpy: DetailViewModelProtocol { + // MARK: - Properties + + weak var delegate: DetailViewModelDelegate? + var assets: [Asset] = [] + var state: DetailViewState = .loading + + public enum Method { + case loadData + case didTapRetry + case didTapWebsite + case didTapTwitter + } + + public var calledMethods: [Method] = [] + + // MARK: - Methods + + func loadData() { + calledMethods.append(.loadData) + } + + func didTapRetry() { + calledMethods.append(.didTapRetry) + } + + func didTapWebsite() { + calledMethods.append(.didTapWebsite) + } + + func didTapTwitter() { + calledMethods.append(.didTapTwitter) + } +} diff --git a/Projects/Detail/Tests/Doubles/NetworkServiceProtocolSpy.swift b/Projects/Detail/Tests/Doubles/NetworkServiceProtocolSpy.swift new file mode 100644 index 0000000..da4fd58 --- /dev/null +++ b/Projects/Detail/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/Detail/Tests/PlaceHolder.swift b/Projects/Detail/Tests/PlaceHolder.swift deleted file mode 100644 index e69de29..0000000 diff --git a/Projects/Detail/Tests/Service/DetailEndpointTests.swift b/Projects/Detail/Tests/Service/DetailEndpointTests.swift new file mode 100644 index 0000000..8734ec6 --- /dev/null +++ b/Projects/Detail/Tests/Service/DetailEndpointTests.swift @@ -0,0 +1,55 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Testing +import Foundation +import NetworkingInterfaces +@testable import Detail + +struct DetailEndpointTests { + + @Test("GIVEN fetchAssets endpoint WHEN accessing path THEN returns correct path with ID parameter") + func testFetchAssetsPath() throws { + let id = 1 + let endpoint = DetailEndpoint.fetchAssets(id: id) + + #expect(endpoint.path == "/v1/exchange/assets?id=1") + } + + @Test("GIVEN fetchAssets endpoint with different ID WHEN accessing path THEN returns correct path") + func testFetchAssetsPathWithDifferentId() throws { + let id = 270 + let endpoint = DetailEndpoint.fetchAssets(id: id) + + #expect(endpoint.path == "/v1/exchange/assets?id=270") + } + + @Test("GIVEN fetchAssets endpoint WHEN accessing method THEN returns GET method") + func testFetchAssetsMethod() throws { + let endpoint = DetailEndpoint.fetchAssets(id: 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 = DetailEndpoint.fetchAssets(id: 1) + let headers = endpoint.headers + + #expect(headers?["X-CMC_PRO_API_KEY"] != nil) + } + + @Test("GIVEN any endpoint WHEN accessing baseURL THEN returns environment variable value") + func testEndpointBaseURL() throws { + let endpoint = DetailEndpoint.fetchAssets(id: 1) + let baseURL = endpoint.baseURL + + #expect(!baseURL.isEmpty || ProcessInfo.processInfo.environment["CM_API_BASE_URL"] == nil) + } +} diff --git a/Projects/Detail/Tests/Service/DetailServiceTests.swift b/Projects/Detail/Tests/Service/DetailServiceTests.swift new file mode 100644 index 0000000..1a16e8b --- /dev/null +++ b/Projects/Detail/Tests/Service/DetailServiceTests.swift @@ -0,0 +1,146 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces +import Testing + +@testable import Detail + +struct DetailServiceTests { + @Test("GIVEN successful response WHEN fetchExchangeAssets is called THEN returns mapped assets") + func testFetchExchangeAssetsSuccess() 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": [ + { + "wallet_address": "0x123", + "balance": 100.0, + "currency": { + "crypto_id": 1, + "price_usd": 50000.0, + "symbol": "BTC", + "name": "Bitcoin" + } + }, + { + "wallet_address": "0x456", + "balance": 200.0, + "currency": { + "crypto_id": 2, + "price_usd": 3000.0, + "symbol": "ETH", + "name": "Ethereum" + } + } + ] + } + """ + let data = try #require(mockResponse.data(using: .utf8)) + let url = try #require(URL(string: "https://api.example.com")) + let httpResponse = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )) + + networkSpy.requestResult = .success((data, httpResponse)) + + let result = try await sut.fetchExchangeAssets(id: 1) + + #expect(networkSpy.calledMethods == [.request]) + #expect(result.count == 2) + #expect(result[0] == Asset(id: 1, name: "Bitcoin", symbol: "BTC", priceUsd: 50000.0)) + #expect(result[1] == Asset(id: 2, name: "Ethereum", symbol: "ETH", priceUsd: 3000.0)) + } + + @Test("GIVEN error response WHEN fetchExchangeAssets is called THEN throws network error") + func testFetchExchangeAssetsNetworkError() async throws { + let (sut, networkSpy) = makeSut() + let mockResponse = """ + { + "timestamp": "2024-01-01T00:00:00.000Z", + "error_code": 404, + "error_message": "Exchange not found", + "elapsed": 10, + "credit_count": 1 + } + """ + let data = try #require(mockResponse.data(using: .utf8)) + let url = try #require(URL(string: "https://api.example.com")) + let httpResponse = try #require(HTTPURLResponse( + url: url, + statusCode: 404, + httpVersion: nil, + headerFields: nil + )) + + networkSpy.requestResult = .success((data, httpResponse)) + + do { + _ = try await sut.fetchExchangeAssets(id: 999) + Issue.record("Expected error to be thrown") + } catch let error as DetailServiceError { + #expect(error == .network(Status( + timestamp: "2024-01-01T00:00:00.000Z", + errorCode: 404, + errorMessage: "Exchange not found", + elapsed: 10, + creditCount: 1 + ))) + #expect(networkSpy.calledMethods == [.request]) + } + } + + @Test("GIVEN decode failure WHEN fetchExchangeAssets is called THEN throws error") + func testFetchExchangeAssetsDecodeFail() async throws { + let (sut, networkSpy) = makeSut() + let mockResponse = """ + { "invalid": "json" } + """ + let data = try #require(mockResponse.data(using: .utf8)) + let url = try #require(URL(string: "https://api.example.com")) + let httpResponse = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )) + + networkSpy.requestResult = .success((data, httpResponse)) + + do { + _ = try await sut.fetchExchangeAssets(id: 1) + Issue.record("Expected error to be thrown") + } catch { + #expect(networkSpy.calledMethods == [.request]) + } + } +} + +extension DetailServiceTests { + typealias SutAndDoubles = ( + sut: DetailService, + networkSpy: NetworkServiceProtocolSpy + ) + + func makeSut() -> SutAndDoubles { + let networkSpy = NetworkServiceProtocolSpy() + let sut = DetailService(networkService: networkSpy) + return (sut, networkSpy) + } +} diff --git a/Projects/Detail/Tests/Service/DetailViewModelCoordinatorDelegateSpy.swift b/Projects/Detail/Tests/Service/DetailViewModelCoordinatorDelegateSpy.swift new file mode 100644 index 0000000..8fae1ae --- /dev/null +++ b/Projects/Detail/Tests/Service/DetailViewModelCoordinatorDelegateSpy.swift @@ -0,0 +1,23 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +@testable import Detail + +@MainActor +final class DetailViewModelCoordinatorDelegateSpy: DetailViewModelCoordinatorDelegate { + enum Method: Equatable { + case didRequestOpenURL(URL) + } + + var calledMethods: [Method] = [] + + func didRequestOpenURL(_ url: URL) { + calledMethods.append(.didRequestOpenURL(url)) + } +} diff --git a/Projects/Detail/Tests/Service/DetailViewModelDelegateSpy.swift b/Projects/Detail/Tests/Service/DetailViewModelDelegateSpy.swift new file mode 100644 index 0000000..36f7b66 --- /dev/null +++ b/Projects/Detail/Tests/Service/DetailViewModelDelegateSpy.swift @@ -0,0 +1,23 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +@testable import Detail + +@MainActor +final class DetailViewModelDelegateSpy: DetailViewModelDelegate { + enum Method: Equatable { + case didUpdateState(DetailViewState) + } + + var calledMethods: [Method] = [] + + func didUpdateState(_ state: DetailViewState) { + calledMethods.append(.didUpdateState(state)) + } +} diff --git a/Projects/Detail/Tests/View/DetailViewControllerTests.swift b/Projects/Detail/Tests/View/DetailViewControllerTests.swift new file mode 100644 index 0000000..ebda350 --- /dev/null +++ b/Projects/Detail/Tests/View/DetailViewControllerTests.swift @@ -0,0 +1,157 @@ +import Testing +import Foundation +@testable import Detail + +@MainActor +struct DetailViewControllerTests { + + // MARK: - viewDidLoad + + @Test("GIVEN DetailViewController WHEN viewDidLoad is called THEN calls loadData on viewModel") + func testViewDidLoadCallsLoadData() { + let (sut, viewModelSpy) = makeSut() + + sut.loadViewIfNeeded() + + #expect(viewModelSpy.calledMethods == [.loadData]) + } + + @Test("GIVEN DetailViewController WHEN viewDidLoad is called THEN sets delegate on viewModel") + func testViewDidLoadSetsDelegate() { + let (sut, viewModelSpy) = makeSut() + + sut.loadViewIfNeeded() + + #expect(viewModelSpy.delegate != nil) + } + + // MARK: - didUpdateState + + @Test("GIVEN DetailViewController WHEN didUpdateState with loading THEN hides tableView and shows loading indicator") + func testDidUpdateStateWithLoading() { + let (sut, _) = makeSut() + sut.loadViewIfNeeded() + + sut.didUpdateState(.loading) + + #expect(true) + } + + @Test("GIVEN DetailViewController WHEN didUpdateState with loaded THEN shows tableView and sets title") + func testDidUpdateStateWithLoaded() { + let (sut, _) = makeSut() + sut.loadViewIfNeeded() + let model = makeDetailModel() + + sut.didUpdateState(.loaded(model)) + + #expect(sut.title == model.name) + } + + @Test("GIVEN DetailViewController WHEN didUpdateState with loadingAssets THEN does not crash") + func testDidUpdateStateWithLoadingAssets() { + let (sut, _) = makeSut() + sut.loadViewIfNeeded() + + sut.didUpdateState(.loadingAssets) + + #expect(true) + } + + @Test("GIVEN DetailViewController WHEN didUpdateState with loadedAssets THEN reloads table data") + func testDidUpdateStateWithLoadedAssets() { + let (sut, _) = makeSut() + sut.loadViewIfNeeded() + sut.didUpdateState(.loaded(makeDetailModel())) + + sut.didUpdateState(.loadedAssets) + + #expect(true) + } + + @Test("GIVEN DetailViewController WHEN didUpdateState with error THEN shows error view") + func testDidUpdateStateWithError() { + let (sut, _) = makeSut() + sut.loadViewIfNeeded() + + sut.didUpdateState(.error("Test error")) + + #expect(true) + } + + @Test("GIVEN DetailViewController WHEN didUpdateState with errorLoadAssets THEN shows error view") + func testDidUpdateStateWithErrorLoadAssets() { + let (sut, _) = makeSut() + sut.loadViewIfNeeded() + sut.didUpdateState(.loaded(makeDetailModel())) + + sut.didUpdateState(.errorLoadAssets(title: "Failed to load assets", message: "Server error", code: 0)) + + #expect(true) + } + + // MARK: - Button actions + + @Test("GIVEN DetailViewController WHEN website button is tapped THEN calls didTapWebsite on viewModel") + func testDidTapWebsiteButton() { + let (sut, viewModelSpy) = makeSut() + sut.loadViewIfNeeded() + viewModelSpy.calledMethods.removeAll() + + sut.perform(NSSelectorFromString("didTapWebsiteButton")) + + #expect(viewModelSpy.calledMethods == [.didTapWebsite]) + } + + @Test("GIVEN DetailViewController WHEN twitter button is tapped THEN calls didTapTwitter on viewModel") + func testDidTapTwitterButton() { + let (sut, viewModelSpy) = makeSut() + sut.loadViewIfNeeded() + viewModelSpy.calledMethods.removeAll() + + sut.perform(NSSelectorFromString("didTapTwitterButton")) + + #expect(viewModelSpy.calledMethods == [.didTapTwitter]) + } + + // MARK: - Table view data source + + @Test("GIVEN DetailViewController with loadedAssets and empty assets WHEN numberOfSections is called THEN returns zero") + func testNumberOfSectionsWithEmptyAssets() { + let (sut, viewModelSpy) = makeSut() + sut.loadViewIfNeeded() + viewModelSpy.state = .loadedAssets + viewModelSpy.assets = [] + + sut.didUpdateState(.loaded(makeDetailModel())) + sut.didUpdateState(.loadedAssets) + + #expect(true) + } +} + +extension DetailViewControllerTests { + private func makeSut() -> (viewController: DetailViewController, viewModelSpy: DetailViewModelSpy) { + let viewModelSpy = DetailViewModelSpy() + let viewController = DetailViewController(viewModel: viewModelSpy) + return (viewController, viewModelSpy) + } + + private func makeDetailModel( + websiteUrl: String? = "https://test.com", + twitterUrl: String? = "https://twitter.com/test" + ) -> ExchangeDetailModel { + ExchangeDetailModel( + id: 1, + name: "Test Exchange", + description: "Test Description", + logoUrl: "https://test.com/logo.png", + spotVolumeUsd: 1000000, + makerFee: 0.1, + takerFee: 0.2, + dateLaunched: "2020-01-01", + websiteUrl: websiteUrl, + twitterUrl: twitterUrl + ) + } +} diff --git a/Projects/Detail/Tests/ViewModel/DetailViewModelTests.swift b/Projects/Detail/Tests/ViewModel/DetailViewModelTests.swift new file mode 100644 index 0000000..52446ec --- /dev/null +++ b/Projects/Detail/Tests/ViewModel/DetailViewModelTests.swift @@ -0,0 +1,181 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Testing +import Foundation +import HomeInterfaces +@testable import Detail + +@MainActor +struct DetailViewModelTests { + + @Test("GIVEN valid exchange WHEN loadData is called THEN fetches assets and updates state to loaded") + func testLoadDataSuccess() async throws { + let (sut, doubles) = makeSut() + let mockAssets = [ + Asset(id: 1, name: "Bitcoin", symbol: "BTC", priceUsd: 50000.0) + ] + doubles.serviceSpy.fetchExchangeAssetsResult = .success(mockAssets) + + sut.configure(with: makeExchange()) + + sut.loadData() + + try await Task.sleep(nanoseconds: 100_000_000) + + let expectedModel = ExchangeDetailModel(from: makeExchange()) + #expect(doubles.serviceSpy.calledMethods == [.fetchExchangeAssets]) + #expect(doubles.delegateSpy.calledMethods == [ + .didUpdateState(.loaded(expectedModel)), + .didUpdateState(.loadedAssets) + ]) + #expect(sut.assets == mockAssets) + } + + @Test("GIVEN no exchange configured WHEN loadData is called THEN updates state to error") + func testLoadDataWithoutConfiguredExchange() async throws { + let (sut, doubles) = makeSut() + + sut.loadData() + + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(doubles.serviceSpy.calledMethods.isEmpty) + #expect(doubles.delegateSpy.calledMethods == [ + .didUpdateState(.error("Invalid Exchanges, please try another option")) + ]) + } + + @Test("GIVEN network error WHEN loadData is called THEN updates state to errorLoadAssets") + func testLoadDataAssetsError() async throws { + let (sut, doubles) = makeSut() + let error = NSError(domain: "test", code: 500, userInfo: [NSLocalizedDescriptionKey: "Server error"]) + doubles.serviceSpy.fetchExchangeAssetsResult = .failure(error) + + sut.configure(with: makeExchange()) + + sut.loadData() + + try await Task.sleep(nanoseconds: 100_000_000) + + let expectedModel = ExchangeDetailModel(from: makeExchange()) + #expect(doubles.serviceSpy.calledMethods == [.fetchExchangeAssets]) + #expect(doubles.delegateSpy.calledMethods == [ + .didUpdateState(.loaded(expectedModel)), + .didUpdateState(.errorLoadAssets( + title: "Failed to load assets", + message: "Server error", + code: 0 + )) + ]) + } + + @Test("GIVEN loaded state with website URL WHEN didTapWebsite is called THEN notifies coordinator") + func testDidTapWebsite() async throws { + let (sut, doubles) = makeSut() + doubles.serviceSpy.fetchExchangeAssetsResult = .success([]) + + sut.configure(with: makeExchange(websiteUrl: "https://binance.com")) + sut.loadData() + try await Task.sleep(nanoseconds: 100_000_000) + + sut.didTapWebsite() + + #expect(doubles.coordinatorDelegateSpy.calledMethods == [ + .didRequestOpenURL(URL(string: "https://binance.com")!) + ]) + } + + @Test("GIVEN loaded state with twitter URL WHEN didTapTwitter is called THEN notifies coordinator") + func testDidTapTwitter() async throws { + let (sut, doubles) = makeSut() + doubles.serviceSpy.fetchExchangeAssetsResult = .success([]) + + sut.configure(with: makeExchange(twitterUrl: "https://twitter.com/binance")) + sut.loadData() + try await Task.sleep(nanoseconds: 100_000_000) + + sut.didTapTwitter() + + #expect(doubles.coordinatorDelegateSpy.calledMethods == [ + .didRequestOpenURL(URL(string: "https://twitter.com/binance")!) + ]) + } + + @Test("GIVEN error state WHEN didTapRetry is called THEN retries loading data") + func testDidTapRetry() async throws { + let (sut, doubles) = makeSut() + let error = NSError(domain: "test", code: 500, userInfo: [NSLocalizedDescriptionKey: "Server error"]) + doubles.serviceSpy.fetchExchangeAssetsResult = .failure(error) + + sut.configure(with: makeExchange()) + sut.loadData() + try await Task.sleep(nanoseconds: 100_000_000) + + let mockAssets = [ + Asset(id: 1, name: "Bitcoin", symbol: "BTC", priceUsd: 50000.0) + ] + doubles.serviceSpy.fetchExchangeAssetsResult = .success(mockAssets) + + sut.didTapRetry() + try await Task.sleep(nanoseconds: 100_000_000) + + #expect(doubles.serviceSpy.calledMethods == [.fetchExchangeAssets, .fetchExchangeAssets]) + #expect(doubles.delegateSpy.calledMethods.last == .didUpdateState(.loadedAssets)) + #expect(sut.assets == mockAssets) + } +} + +extension DetailViewModelTests { + typealias SutAndDoubles = ( + sut: DetailViewModel, + doubles: ( + serviceSpy: DetailServiceProtocolSpy, + delegateSpy: DetailViewModelDelegateSpy, + coordinatorDelegateSpy: DetailViewModelCoordinatorDelegateSpy + ) + ) + + func makeSut() -> SutAndDoubles { + let serviceSpy = DetailServiceProtocolSpy() + let delegateSpy = DetailViewModelDelegateSpy() + let coordinatorDelegateSpy = DetailViewModelCoordinatorDelegateSpy() + + let sut = DetailViewModel(service: serviceSpy) + sut.delegate = delegateSpy + sut.coordinatorDelegate = coordinatorDelegateSpy + + return (sut, (serviceSpy, delegateSpy, coordinatorDelegateSpy)) + } + + func makeExchange( + id: Int = 1, + name: String = "Binance", + description: String? = "Binance Exchange", + logo: String = "https://logo.url", + spotVolumeUsd: Double? = 1000000.0, + makerFee: Double = 0.1, + takerFee: Double = 0.2, + dateLaunched: String = "2017-07-14", + websiteUrl: String? = nil, + twitterUrl: String? = nil + ) -> Exchange { + Exchange( + id: id, + name: name, + description: description, + logo: logo, + spotVolumeUsd: spotVolumeUsd, + makerFee: makerFee, + takerFee: takerFee, + dateLaunched: dateLaunched, + websiteUrl: websiteUrl, + twitterUrl: twitterUrl + ) + } +} diff --git a/Projects/Home/Interfaces/Model/Status.swift b/Projects/Home/Interfaces/Model/Status.swift index 7de661a..eaf5596 100644 --- a/Projects/Home/Interfaces/Model/Status.swift +++ b/Projects/Home/Interfaces/Model/Status.swift @@ -8,7 +8,7 @@ import Foundation -public struct Status: Equatable { +public struct Status: Equatable, Sendable { let timestamp: String let errorCode: Int? let errorMessage: String?