diff --git a/.github/workflows/pr_status.yml b/.github/workflows/pr_status.yml index b691df6..d72a13f 100644 --- a/.github/workflows/pr_status.yml +++ b/.github/workflows/pr_status.yml @@ -28,6 +28,8 @@ jobs: - 'Projects/Home/**' detail: - 'Projects/Detail/**' + helpers: + - 'Projects/Helpers/**' - name: Generate matrix id: matrix run: | @@ -51,6 +53,10 @@ jobs: if [ "${{ steps.filter.outputs.detail }}" == "true" ]; then targets+=("Detail") fi + + if [ "${{ steps.filter.outputs.helpers }}" == "true" ]; then + targets+=("Helpers") + fi if [ ${#targets[@]} -gt 0 ]; then matrix="{\"target\": [$(printf '"%s",' "${targets[@]}" | sed 's/,$//')]}" diff --git a/Projects/Detail/Sources/Model/ExchangeDetailModel.swift b/Projects/Detail/Sources/Model/ExchangeDetailModel.swift index 75bcd82..f059202 100644 --- a/Projects/Detail/Sources/Model/ExchangeDetailModel.swift +++ b/Projects/Detail/Sources/Model/ExchangeDetailModel.swift @@ -14,7 +14,7 @@ struct ExchangeDetailModel: Equatable { let name: String let description: String let logoUrl: String - let spotVolumeUsd: Double? + let spotVolumeUsd: String let makerFee: Double let takerFee: Double let dateLaunched: String diff --git a/Projects/Detail/Sources/View/DetailHeaderView.swift b/Projects/Detail/Sources/View/DetailHeaderView.swift index c156584..f2f92e7 100644 --- a/Projects/Detail/Sources/View/DetailHeaderView.swift +++ b/Projects/Detail/Sources/View/DetailHeaderView.swift @@ -144,7 +144,7 @@ final class DetailHeaderView: UIView { func configure(with model: ExchangeDetailModel) { titleLabel.text = "\(model.name) - ID \(model.id)" descriptionLabel.text = model.description - dateLaunchedLabel.text = "Date launched: \(model.dateLaunched)" + dateLaunchedLabel.text = model.dateLaunched makerFeeLabel.text = "Maker: \(formatPercentage(model.makerFee))" takerFeeLabel.text = "Taker: \(formatPercentage(model.takerFee))" loadImage(from: model.logoUrl) diff --git a/Projects/Detail/Tests/Coordinator/DetailCoordinatorTests.swift b/Projects/Detail/Tests/Coordinator/DetailCoordinatorTests.swift index 68ce71e..4cac398 100644 --- a/Projects/Detail/Tests/Coordinator/DetailCoordinatorTests.swift +++ b/Projects/Detail/Tests/Coordinator/DetailCoordinatorTests.swift @@ -100,7 +100,7 @@ extension DetailCoordinatorTests { name: "Binance", description: "Binance Exchange", logo: "https://logo.url", - spotVolumeUsd: 1000000.0, + spotVolumeUsd: "$ 1.000.000,0", makerFee: 0.1, takerFee: 0.2, dateLaunched: "2017-07-14", diff --git a/Projects/Detail/Tests/View/DetailViewControllerTests.swift b/Projects/Detail/Tests/View/DetailViewControllerTests.swift index ebda350..1c67548 100644 --- a/Projects/Detail/Tests/View/DetailViewControllerTests.swift +++ b/Projects/Detail/Tests/View/DetailViewControllerTests.swift @@ -146,7 +146,7 @@ extension DetailViewControllerTests { name: "Test Exchange", description: "Test Description", logoUrl: "https://test.com/logo.png", - spotVolumeUsd: 1000000, + spotVolumeUsd: "$ 1.000.000,0", makerFee: 0.1, takerFee: 0.2, dateLaunched: "2020-01-01", diff --git a/Projects/Detail/Tests/ViewModel/DetailViewModelTests.swift b/Projects/Detail/Tests/ViewModel/DetailViewModelTests.swift index 52446ec..66cdc8a 100644 --- a/Projects/Detail/Tests/ViewModel/DetailViewModelTests.swift +++ b/Projects/Detail/Tests/ViewModel/DetailViewModelTests.swift @@ -158,7 +158,7 @@ extension DetailViewModelTests { name: String = "Binance", description: String? = "Binance Exchange", logo: String = "https://logo.url", - spotVolumeUsd: Double? = 1000000.0, + spotVolumeUsd: String = "$ 1.000.000,0", makerFee: Double = 0.1, takerFee: Double = 0.2, dateLaunched: String = "2017-07-14", diff --git a/Projects/Helpers/Project.swift b/Projects/Helpers/Project.swift new file mode 100644 index 0000000..1627c69 --- /dev/null +++ b/Projects/Helpers/Project.swift @@ -0,0 +1,7 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let moduleName = "Helpers" + +let project = Project.templateModule(named: moduleName, + targets: [.source, .test]) diff --git a/Projects/Helpers/Sources/NumbersFormatter.swift b/Projects/Helpers/Sources/NumbersFormatter.swift new file mode 100644 index 0000000..0a0c58d --- /dev/null +++ b/Projects/Helpers/Sources/NumbersFormatter.swift @@ -0,0 +1,32 @@ +// +// NumberFormatter.swift +// Helpers +// +// Created by Vitor Conceicao on 23/02/26. +// + +import Foundation + +public struct NumbersFormatter { + let locale: Locale + + public init(locale: Locale = .current) { + self.locale = locale + } + + public func formatPrice(_ value: Double?) -> String { + guard let value else { return "-" } + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencySymbol = "$" + formatter.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + formatter.usesGroupingSeparator = true + formatter.groupingSeparator = "." + formatter.decimalSeparator = "," + + formatter.locale = locale + + return formatter.string(from: NSNumber(value: value)) ?? "-" + } +} diff --git a/Projects/Helpers/Sources/StringFormatter.swift b/Projects/Helpers/Sources/StringFormatter.swift new file mode 100644 index 0000000..1160ddf --- /dev/null +++ b/Projects/Helpers/Sources/StringFormatter.swift @@ -0,0 +1,32 @@ +// +// DateStringFormatter.swift +// Helpers +// +// Created by Vitor Conceicao on 23/02/26. +// + +import Foundation + +public struct StringFormatter { + let locale: Locale + + public init(locale: Locale = .current) { + self.locale = locale + } + + public func formatDate(_ date: String) -> String { + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let date = isoFormatter.date(from: date) else { + return date + } + + let displayFormatter = DateFormatter() + displayFormatter.dateStyle = .short + displayFormatter.timeStyle = .none + displayFormatter.locale = locale + + return displayFormatter.string(from: date) + } +} diff --git a/Projects/Helpers/Tests/NumbersFormatterTests.swift b/Projects/Helpers/Tests/NumbersFormatterTests.swift new file mode 100644 index 0000000..bf09e7f --- /dev/null +++ b/Projects/Helpers/Tests/NumbersFormatterTests.swift @@ -0,0 +1,53 @@ +// +// NumbersFormatterTests.swift +// Helpers +// +// Created by Vitor Conceicao on 23/02/26. +// + +import Foundation +import Testing + +@testable import Helpers + +@Suite("StringFormatter Tests") +struct NumbersFormatterTests { + @Test("GIVEN a value WHEN formatPrice with pt_BR locale THEN returns formatted currency string", + arguments: [ + (1000000.50, "1.000.000,50"), + (0.0, "0,00"), + (1234.5, "1.234,50") + ]) + func testFormatPriceWithPtBR(value: Double, expectedString: String) { + let locale = Locale(identifier: "pt_BR") + let formatter = NumbersFormatter(locale: locale) + + let result = formatter.formatPrice(value) + + #expect(result.contains(expectedString)) + } + + @Test("GIVEN a value WHEN formatPrice with en_US locale THEN returns formatted currency string", + arguments: [ + (1000000.50, "1,000,000.50"), + (0.0, "0.00"), + (1234.5, "1,234.50") + ]) + func testFormatPriceEnUs(value: Double, expectedString: String) { + let locale = Locale(identifier: "en_US") + let formatter = NumbersFormatter(locale: locale) + + let result = formatter.formatPrice(value) + + #expect(result.contains(expectedString)) + } + + @Test("GIVEN a nil value WHEN formatPrice THEN returns default value") + func testNilValue() { + let formatter = NumbersFormatter() + + let result = formatter.formatPrice(nil) + + #expect(result == "-") + } +} diff --git a/Projects/Helpers/Tests/StringFormatterTests.swift b/Projects/Helpers/Tests/StringFormatterTests.swift new file mode 100644 index 0000000..e056004 --- /dev/null +++ b/Projects/Helpers/Tests/StringFormatterTests.swift @@ -0,0 +1,52 @@ +// +// StringFormatter.swift +// Helpers +// +// Created by Vitor Conceicao on 23/02/26. +// + +import Foundation +import Testing + +@testable import Helpers + +@Suite("StringFormatter Tests") +struct StringFormatterTests { + + @Test("GIVEN a valid ISO8601 date string WHEN formatDate is called with custom locale THEN returns formatted date for that locale") + func testFormatDateWithCustomLocale() { + let dateString = "2026-02-23T15:30:00.000Z" + let usLocale = Locale(identifier: "en_US") + let expected = "2/23/26" + + let result = StringFormatter(locale: usLocale).formatDate(dateString) + + #expect(result == expected) + } + + @Test("GIVEN a date string with different locale WHEN formatDate is called with pt_BR locale THEN returns formatted date for Brazilian Portuguese") + func testFormatDateWithBrazilianLocale() { + let dateString = "2026-02-23T10:00:00.000Z" + let ptBRLocale = Locale(identifier: "pt_BR") + + let result = StringFormatter(locale: ptBRLocale).formatDate(dateString) + + let expected = "23/02/2026" + + #expect(result == expected) + } + + @Test("GIVEN various invalid date strings WHEN formatDate is called THEN returns the original strings", + arguments: [ + "invalid-date", + "2020-13-01", + "not a date at all", + "12345", + "2020/01/01" + ]) + func testFormatDateVariousInvalidDates(dateString: String) { + let result = StringFormatter().formatDate(dateString) + + #expect(result == dateString) + } +} diff --git a/Projects/Home/Interfaces/Model/Exchange.swift b/Projects/Home/Interfaces/Model/Exchange.swift index bf63b73..da7d624 100644 --- a/Projects/Home/Interfaces/Model/Exchange.swift +++ b/Projects/Home/Interfaces/Model/Exchange.swift @@ -13,7 +13,7 @@ public struct Exchange: Equatable, Identifiable { public let name: String public let description: String? public let logo: String - public let spotVolumeUsd: Double? + public let spotVolumeUsd: String public let makerFee: Double public let takerFee: Double public let dateLaunched: String @@ -26,7 +26,7 @@ public struct Exchange: Equatable, Identifiable { name: String, description: String? = nil, logo: String, - spotVolumeUsd: Double? = nil, + spotVolumeUsd: String = "", makerFee: Double, takerFee: Double, dateLaunched: String, @@ -50,7 +50,7 @@ public struct Exchange: Equatable, Identifiable { self.name = summary.name self.description = nil self.logo = "" - self.spotVolumeUsd = nil + self.spotVolumeUsd = "" self.makerFee = 0.0 self.takerFee = 0.0 self.dateLaunched = "" diff --git a/Projects/Home/Project.swift b/Projects/Home/Project.swift index 9db5cf3..53f136c 100644 --- a/Projects/Home/Project.swift +++ b/Projects/Home/Project.swift @@ -10,6 +10,8 @@ let dependecies: [TargetDependency] = [ path: "../DesignSystem"), .project(target: "DetailInterfaces", path: "../Detail"), + .project(target: "Helpers", + path: "../Helpers"), .external(name: "NetworkingInterfaces"), ] diff --git a/Projects/Home/Sources/Coordinator/HomeCoordinator.swift b/Projects/Home/Sources/Coordinator/HomeCoordinator.swift index 5c3e911..26d5cb2 100644 --- a/Projects/Home/Sources/Coordinator/HomeCoordinator.swift +++ b/Projects/Home/Sources/Coordinator/HomeCoordinator.swift @@ -8,6 +8,7 @@ import DependencyInjectionInterfaces import DetailInterfaces +import Helpers import HomeInterfaces import NavigationInterfaces import NetworkingInterfaces @@ -28,7 +29,9 @@ public final class HomeCoordinator: HomeCoordinating { let resolver = SharedContainer.shared.resolver() let networkService: NetworkServiceProtocol = resolver.resolve() let service = HomeService(networkService: networkService) - let viewModel = HomeViewModel(service: service) + let viewModel = HomeViewModel(service: service, + stringFormatter: .init(), + numbersFormatter: .init()) viewModel.coordinatorDelegate = self let homeVC = HomeViewController(viewModel: viewModel) navigationController.pushViewController(homeVC, animated: false) diff --git a/Projects/Home/Sources/Protocols/HomeViewModelProtocol.swift b/Projects/Home/Sources/Protocols/HomeViewModelProtocol.swift index 7f67cca..46c6daf 100644 --- a/Projects/Home/Sources/Protocols/HomeViewModelProtocol.swift +++ b/Projects/Home/Sources/Protocols/HomeViewModelProtocol.swift @@ -18,6 +18,4 @@ protocol HomeViewModelProtocol: AnyObject { func loadData() func item(at index: Int) -> Exchange func didSelectRow(at index: Int) - func formatPrice(_ value: Double) -> String - func formatDate(_ date: String) -> String } diff --git a/Projects/Home/Sources/View/HomeViewController.swift b/Projects/Home/Sources/View/HomeViewController.swift index d938108..ac8050d 100644 --- a/Projects/Home/Sources/View/HomeViewController.swift +++ b/Projects/Home/Sources/View/HomeViewController.swift @@ -7,6 +7,7 @@ // import DesignSystem +import Helpers import UIKit class HomeViewController: UIViewController { @@ -142,8 +143,8 @@ extension HomeViewController: UITableViewDataSource, UITableViewDelegate { if item.isLoadingDetails { cell.configure(state: .partialLoaded(.init(title: item.name))) } else { - let volPrice = "Vol: \(viewModel.formatPrice(item.spotVolumeUsd ?? 0.0))" - let dateLaunched = "Date launched: \(viewModel.formatDate(item.dateLaunched))" + let volPrice = "Vol: \(item.spotVolumeUsd)" + let dateLaunched = item.dateLaunched cell.configure(state: .loaded(.init(url: item.logo, title: item.name, subtitle: volPrice, diff --git a/Projects/Home/Sources/ViewModel/HomeViewModel.swift b/Projects/Home/Sources/ViewModel/HomeViewModel.swift index 182c93a..6139bf6 100644 --- a/Projects/Home/Sources/ViewModel/HomeViewModel.swift +++ b/Projects/Home/Sources/ViewModel/HomeViewModel.swift @@ -6,8 +6,9 @@ // // -import HomeInterfaces import Foundation +import Helpers +import HomeInterfaces @MainActor final class HomeViewModel: HomeViewModelProtocol { @@ -15,6 +16,8 @@ final class HomeViewModel: HomeViewModelProtocol { weak var coordinatorDelegate: HomeViewModelCoordinatorDelegate? private let service: HomeServiceProtocol + private let stringFormatter: StringFormatter + private let numbersFormatter: NumbersFormatter private let defaultMessage = "Failed to load data. Press try again or check your connection." private var currentPage = 1 @@ -28,8 +31,12 @@ final class HomeViewModel: HomeViewModelProtocol { var numberOfItems: Int { exchanges.count } - init(service: HomeServiceProtocol) { + init(service: HomeServiceProtocol, + stringFormatter: StringFormatter, + numbersFormatter: NumbersFormatter) { self.service = service + self.stringFormatter = stringFormatter + self.numbersFormatter = numbersFormatter } func loadData() { @@ -107,7 +114,6 @@ final class HomeViewModel: HomeViewModelProtocol { } func didSelectRow(at index: Int) { - guard exchanges.indices.contains(index) else { return } coordinatorDelegate?.navigateToDetails(of: exchanges[index]) } @@ -116,42 +122,11 @@ final class HomeViewModel: HomeViewModelProtocol { name: item.name, description: detail.description, logo: detail.logo, - spotVolumeUsd: detail.spotVolumeUsd, + spotVolumeUsd: numbersFormatter.formatPrice(detail.spotVolumeUsd), makerFee: detail.makerFee, takerFee: detail.takerFee, - dateLaunched: detail.dateLaunched, + dateLaunched: "Date launched: \(stringFormatter.formatDate(detail.dateLaunched))", websiteUrl: detail.urls.website.first, twitterUrl: detail.urls.twitter.first) } - - func formatPrice(_ value: Double) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencySymbol = "$" - formatter.minimumFractionDigits = 2 - formatter.maximumFractionDigits = 2 - formatter.usesGroupingSeparator = true - formatter.groupingSeparator = "." - formatter.decimalSeparator = "," - - formatter.locale = Locale(identifier: "pt_BR") - - return formatter.string(from: NSNumber(value: value)) ?? "$ 0,00" - } - - func formatDate(_ date: String) -> String { - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - guard let date = isoFormatter.date(from: date) else { - return date - } - - let displayFormatter = DateFormatter() - displayFormatter.dateStyle = .short - displayFormatter.timeStyle = .none - displayFormatter.locale = Locale.current - - return displayFormatter.string(from: date) - } } diff --git a/Projects/Home/Tests/Doubles/HomeViewModelSpy.swift b/Projects/Home/Tests/Doubles/HomeViewModelSpy.swift index 242b9e7..590e70f 100644 --- a/Projects/Home/Tests/Doubles/HomeViewModelSpy.swift +++ b/Projects/Home/Tests/Doubles/HomeViewModelSpy.swift @@ -19,8 +19,6 @@ final class HomeViewModelSpy: HomeViewModelProtocol { case loadData case item case didSelectRow - case formatPrice - case formatDate } var calledMethods: [Method] = [] @@ -40,14 +38,4 @@ final class HomeViewModelSpy: HomeViewModelProtocol { func didSelectRow(at index: Int) { calledMethods.append(.didSelectRow) } - - func formatPrice(_ value: Double) -> String { - calledMethods.append(.formatPrice) - return "" - } - - func formatDate(_ date: String) -> String { - calledMethods.append(.formatDate) - return "" - } } diff --git a/Projects/Home/Tests/View/HomeViewControllerTests.swift b/Projects/Home/Tests/View/HomeViewControllerTests.swift index b7044cf..de70002 100644 --- a/Projects/Home/Tests/View/HomeViewControllerTests.swift +++ b/Projects/Home/Tests/View/HomeViewControllerTests.swift @@ -93,8 +93,6 @@ struct HomeViewControllerTests { #expect(cell is InfoCell) #expect(viewModelSpy.calledMethods.contains(.item)) - #expect(viewModelSpy.calledMethods.contains(.formatPrice)) - #expect(viewModelSpy.calledMethods.contains(.formatDate)) } @Test("GIVEN viewController WHEN tableView didSelectRow is called THEN calls viewModel didSelectRow") diff --git a/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift b/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift index 907ae14..9276b9f 100644 --- a/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift +++ b/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift @@ -125,17 +125,17 @@ struct HomeViewModelTests { spotVolumeUsd: 1000000.0, makerFee: 0.001, takerFee: 0.002, - dateLaunched: "2017-07-14", + dateLaunched: "14-07-2017", 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, + spotVolumeUsd: "$ 1.000.000,00", makerFee: 0.001, takerFee: 0.002, - dateLaunched: "2017-07-14", + dateLaunched: "Date launched: 14-07-2017", websiteUrl: "https://binance.com", twitterUrl: "@binance" ) @@ -151,48 +151,6 @@ struct HomeViewModelTests { #expect(doubles.coordinatorDelegateSpy.calledMethods == [.navigateToDetails(exchange)]) } - @Test("GIVEN a value WHEN formatPrice is called THEN returns formatted currency string", - arguments: [ - (1000000.50, "1.000.000,50"), - (0.0, "0,00"), - (1234.5, "1.234,50") - ]) - func testFormatPrice(value: Double, expectedString: String) { - let (sut, _) = makeSut() - - let result = sut.formatPrice(value) - - #expect(result.contains(expectedString)) - } - - @Test("GIVEN a valid ISO8601 date WHEN formatDate is called THEN returns localized short date") - func testFormatDateValid() { - let (sut, _) = makeSut() - - let result = sut.formatDate("2017-07-14T00:00:00.000Z") - - let expectedFormatter = DateFormatter() - expectedFormatter.dateStyle = .short - expectedFormatter.timeStyle = .none - expectedFormatter.locale = Locale.current - - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let date = isoFormatter.date(from: "2017-07-14T00:00:00.000Z")! - let expected = expectedFormatter.string(from: date) - - #expect(result == expected) - } - - @Test("GIVEN an invalid date string WHEN formatDate is called THEN returns the original string") - func testFormatDateInvalid() { - let (sut, _) = makeSut() - - let result = sut.formatDate("not-a-date") - - #expect(result == "not-a-date") - } - @Test("GIVEN loaded exchanges WHEN item is requested at valid index THEN returns correct exchange") func testItemAtIndex() async throws { let (sut, doubles) = makeSut() @@ -256,8 +214,10 @@ extension HomeViewModelTests { let serviceSpy = HomeServiceProtocolSpy() let delegateSpy = HomeViewModelDelegateSpy() let coordinatorDelegateSpy = HomeViewModelCoordinatorDelegateSpy() - - let sut = HomeViewModel(service: serviceSpy) + let ptBRLocale = Locale(identifier: "pt_BR") + let sut = HomeViewModel(service: serviceSpy, + stringFormatter: .init(locale: ptBRLocale), + numbersFormatter: .init(locale: ptBRLocale)) sut.delegate = delegateSpy sut.coordinatorDelegate = coordinatorDelegateSpy diff --git a/Workspace.swift b/Workspace.swift index cd635ed..d5aec55 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -5,9 +5,10 @@ let workspace = Workspace( name: "ExchangesApp", projects: [ "./Projects/App", + "./Projects/Home", + "./Projects/Helpers", "./Projects/DependencyInjection", "./Projects/DesignSystem", - "./Projects/Home", "./Projects/Detail" ], schemes: [],