diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1c4f673 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +## Description +Description + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Refactor (no functional changes) +- [ ] Test update \ No newline at end of file diff --git a/.github/workflows/pr_status.yml b/.github/workflows/pr_status.yml new file mode 100644 index 0000000..b691df6 --- /dev/null +++ b/.github/workflows/pr_status.yml @@ -0,0 +1,152 @@ +name: Pull request workflow + +on: + pull_request: + branches: [ "develop", "main" ] + +jobs: + paths-filter: + name: Verify targets to run tests + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + - name: Check for file changes + uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + app: + - 'Projects/App/**' + dependency_injection: + - 'Projects/DependencyInjection/**' + design_system: + - 'Projects/DesignSystem/**' + home: + - 'Projects/Home/**' + detail: + - 'Projects/Detail/**' + - name: Generate matrix + id: matrix + run: | + targets=() + if [ "${{ steps.filter.outputs.app }}" == "true" ]; then + targets+=("App") + fi + + if [ "${{ steps.filter.outputs.dependency_injection }}" == "true" ]; then + targets+=("DependencyInjection") + fi + + if [ "${{ steps.filter.outputs.design_system }}" == "true" ]; then + targets+=("DesignSystem") + fi + + if [ "${{ steps.filter.outputs.home }}" == "true" ]; then + targets+=("Home") + fi + + if [ "${{ steps.filter.outputs.detail }}" == "true" ]; then + targets+=("Detail") + fi + + if [ ${#targets[@]} -gt 0 ]; then + matrix="{\"target\": [$(printf '"%s",' "${targets[@]}" | sed 's/,$//')]}" + else + matrix='{"target": []}' + fi + echo "matrix=$matrix" >> $GITHUB_OUTPUT + + test: + name: Test ${{ matrix.target }} + runs-on: macos-26 + needs: paths-filter + if: fromJson(needs.paths-filter.outputs.matrix).target[0] != null + strategy: + matrix: ${{ fromJson(needs.paths-filter.outputs.matrix) }} + permissions: + pull-requests: write + issues: write + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + - name: CI Setup + run: make ci-setup + - name: Install dependencies + run: tuist install + - name: Generate project + run: tuist generate --no-open + - name: Run tests for ${{ matrix.target }} + run: exec fastlane tests target:${{ matrix.target }} + - name: Generate code coverage + run: fastlane tests_coverage target:${{ matrix.target }} + - name: Zip reports + run: zip -r reports-${{ matrix.target }}.zip reports-${{ matrix.target }}/test_output/ + - name: Upload test reports + uses: actions/upload-artifact@v4 + with: + name: test-reports-${{ matrix.target }} + path: reports-${{ matrix.target }}.zip + + danger: + name: Danger Code Review + runs-on: ubuntu-latest + needs: [test, paths-filter] + if: | + needs.paths-filter.result == 'success' && + fromJson(needs.paths-filter.outputs.matrix).target[0] != null && + needs.test.result == 'success' + permissions: + pull-requests: write + contents: read + statuses: write + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + with: + fetch-depth: 100 + - name: Download All Coverage Artifacts + uses: actions/download-artifact@v4 + with: + path: all-reports + pattern: test-reports-* + merge-multiple: true + - name: Unzip reports + run: cd all-reports && unzip "*.zip" + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-with: local + - uses: actions/cache@v4 + with: + path: vendor/bundle + key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }} # change your gemfile path + restore-keys: | + ${{ runner.os }}-gems- + - name: Run Danger + uses: MeilCli/danger-action@v6 + with: + plugins_file: 'Gemfile' + install_path: 'vendor/bundle' + danger_file: 'Dangerfile' + danger_id: 'danger-pr' + env: + DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + check-project: + name: Check project generation + runs-on: macos-26 + needs: paths-filter + if: fromJson(needs.paths-filter.outputs.matrix).target[0] == null + steps: + - name: Checkout + uses: actions/checkout@v4.1.1 + - name: CI Setup + run: make ci-setup + - name: Install dependencies + run: tuist install + - name: Check project generation + run: tuist generate --no-open \ No newline at end of file diff --git a/.gitignore b/.gitignore index 52fe2f7..b582d5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,32 @@ +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Xcode ### # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore @@ -5,58 +34,46 @@ ## User settings xcuserdata/ -## Obj-C/Swift specific -*.hmap +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout -## App packaging -*.ipa -*.dSYM.zip -*.dSYM +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 -## Playgrounds -timeline.xctimeline -playground.xcworkspace +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno -# Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm +### Projects ### +*.xcodeproj +*.xcworkspace -.build/ +### Tuist derived files ### +graph.dot +Derived/ -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control +### Tuist managed dependencies ### +Tuist/.build fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output +fastlane/README.md +test_output +derived_data +Gemfile.lock +.bundle +reports-*/ +.env \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100755 index 0000000..6c36a9e --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,4 @@ +excluded: + - "**/Tests" + - "**/UITests" + - "**/Project.swift" \ No newline at end of file diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000..fad43d8 --- /dev/null +++ b/Brewfile @@ -0,0 +1,5 @@ +tap "tuist/tuist" + +brew "tuist" +brew "swiftlint" +brew "xcbeautify" \ No newline at end of file diff --git a/Dangerfile b/Dangerfile new file mode 100644 index 0000000..3f3d73a --- /dev/null +++ b/Dangerfile @@ -0,0 +1,40 @@ +require 'rexml/document' + +# 1. Basic PR checks +warn("PR is still Work in Progress") if github.pr_title.include?("WIP") + +# 2. Report Coverage +coverage_folder = "all-reports" + +Dir.glob("#{coverage_folder}/reports-*/test_output/cobertura.xml").each do |xml_file| + target_name = xml_file.match(/reports-(.+?)\//)[1] + doc = REXML::Document.new(File.read(xml_file)) + coverage_element = doc.root + overall_rate = coverage_element.attributes["line-rate"].to_f + overall_percentage = (overall_rate * 100).to_i + + message("Code coverage for #{target_name}: #{overall_percentage}%") + warn("Code coverage for #{target_name} is below 80%: #{overall_percentage}%") if overall_rate < 0.8 + + # Build table for files + table = "| File | Coverage |\n|------|----------|\n" + files_added = 0 + + doc.elements.each("//class") do |cls| + file_name = cls.attributes["filename"] + + # --- FILTRO ADICIONADO AQUI --- + # Verifica se o nome do arquivo termina com .swift + next unless file_name.end_with?(".swift") + + line_rate = cls.attributes["line-rate"].to_f + file_percentage = (line_rate * 100).to_i + table += "| #{file_name} | #{file_percentage}% |\n" + files_added += 1 + end + + # Só imprime a tabela se houver arquivos Swift encontrados + if files_added > 0 + markdown("### Coverage Details for #{target_name}\n#{table}") + end +end \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..6c98236 --- /dev/null +++ b/Gemfile @@ -0,0 +1,8 @@ +source "https://rubygems.org" + +gem "fastlane", "~> 2.232.0" +gem 'slather', "~> 2.8.5" + +group :local do + gem 'danger' +end \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8a9ec23 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +generate-project: + @sh ./scripts/generate-project.sh + +local-setup: + @sh ./scripts/local-setup.sh + +ci-setup: + @sh ./scripts/ci-setup.sh \ No newline at end of file diff --git a/Projects/App/Info.plist b/Projects/App/Info.plist new file mode 100644 index 0000000..c60398b --- /dev/null +++ b/Projects/App/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + UILaunchStoryboardName + LaunchScreen + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionAllowsInsecureHTTPLoads + + NSExceptionMinimumTLSVersion + TLSv1.2 + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + \ No newline at end of file diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift new file mode 100644 index 0000000..4176c82 --- /dev/null +++ b/Projects/App/Project.swift @@ -0,0 +1,86 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let moduleName = "App" + +let dependecies: [TargetDependency] = [ + .project(target: "DependencyInjection", + path: "../DependencyInjection"), + .project(target: "DependencyInjectionInterfaces", + path: "../DependencyInjection"), + .project(target: "Home", + path: "../Home"), + .project(target: "HomeInterfaces", + path: "../Home"), + .project(target: "Detail", + path: "../Detail"), + .project(target: "DetailInterfaces", + path: "../Detail"), + .project(target: "NavigationInterfaces", + path: "../Navigation"), + .external(name: "Networking"), + .external(name: "NetworkingInterfaces"), +] + +let testDependencies: [TargetDependency] = [ + .target(name: moduleName), + .project(target: "DependencyInjectionTesting", + path: "../DependencyInjection"), + .project(target: "HomeTesting", + path: "../Home"), + .project(target: "NavigationTesting", + path: "../Navigation"), +] + +let project = Project( + name: moduleName, + options: .options(automaticSchemesOptions: .disabled), + settings: .settings( + base: commonSettings + ), + targets: [ + .target(name: moduleName, + destinations: .iOS, + product: .app, + bundleId: "com.vrc.exchanges.\(moduleName.lowercased())", + deploymentTargets: iOSDeploymentTarget, + infoPlist: "Info.plist", + buildableFolders: ["Sources", "Resources"], + scripts: [ + swiftLintScript + ], + dependencies: dependecies + ), + .target(name: "\(moduleName)Tests", + destinations: .iOS, + product: .unitTests, + bundleId: "com.vrc.exchanges.\(moduleName.lowercased()).tests", + deploymentTargets: iOSDeploymentTarget, + infoPlist: .default, + buildableFolders: ["Tests"], + dependencies: testDependencies), + .target(name: "\(moduleName)UITests", + destinations: .iOS, + product: .uiTests, + bundleId: "com.vrc.exchanges.\(moduleName.lowercased()).uitests", + deploymentTargets: iOSDeploymentTarget, + infoPlist: .default, + buildableFolders: ["UITests"], + dependencies: testDependencies + [.xctest]) + + ], + schemes: [ + .scheme(name: moduleName, + shared: true, + buildAction: .buildAction(targets: [.project(path: ".", target: moduleName)]), + testAction: .targets([ + "\(moduleName)Tests", + // Disabled UITests for now, as they are not yet implemented +// "\(moduleName)UITests" + ], + arguments: .arguments(environmentVariables: ["IS_TESTING": "true"])), + runAction: .runAction(configuration: .debug, + executable: .project(path: ".", target: moduleName), + arguments: .arguments(environmentVariables: envs))) + ] +) diff --git a/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Projects/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/Projects/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/App/Resources/Assets.xcassets/Contents.json b/Projects/App/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Projects/App/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Projects/App/Resources/Base.lproj/LaunchScreen.storyboard b/Projects/App/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..fb0e9a4 --- /dev/null +++ b/Projects/App/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Projects/App/Sources/Application/AppDelegate.swift b/Projects/App/Sources/Application/AppDelegate.swift new file mode 100644 index 0000000..70912bf --- /dev/null +++ b/Projects/App/Sources/Application/AppDelegate.swift @@ -0,0 +1,42 @@ +// +// AppDelegate.swift +// ExchangesApp +// +// Created by Vitor Conceicao on 06/02/26. +// + +import DependencyInjection +import DependencyInjectionInterfaces +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + override init() { + super.init() + setupDependencies() + } + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) {} + + @MainActor + func setupDependencies() { + let injector = Injector() + SharedContainer.shared.setInjector(injector) + + NetworkAssembly(injector: injector).register() + CoordinatorAssembly(injector: injector).register() + } +} diff --git a/Projects/App/Sources/Application/SceneDelegate.swift b/Projects/App/Sources/Application/SceneDelegate.swift new file mode 100644 index 0000000..532eb1c --- /dev/null +++ b/Projects/App/Sources/Application/SceneDelegate.swift @@ -0,0 +1,43 @@ +// +// SceneDelegate.swift +// ExchangesApp +// +// Created by Vitor Conceicao on 06/02/26. +// + +import UIKit +import DependencyInjectionInterfaces + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + var appCoordinator: AppCoordinating? + + #if DEBUG + var isTesting: Bool { + return ProcessInfo.processInfo.environment["IS_TESTING"] == "true" + } + #endif + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + #if DEBUG + if isTesting { return } + #endif + + let window = UIWindow(windowScene: windowScene) + let nav = UINavigationController() + let resolver = SharedContainer.shared.resolver() + + appCoordinator = resolver.resolve(AppCoordinating.self, argument: nav) + appCoordinator?.start() + + window.rootViewController = nav + window.makeKeyAndVisible() + + self.window = window + } +} diff --git a/Projects/App/Sources/Assemblies/CoordinatorAssembly.swift b/Projects/App/Sources/Assemblies/CoordinatorAssembly.swift new file mode 100644 index 0000000..04071b4 --- /dev/null +++ b/Projects/App/Sources/Assemblies/CoordinatorAssembly.swift @@ -0,0 +1,53 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// +import DependencyInjectionInterfaces +import Detail +import DetailInterfaces +import HomeInterfaces +import Home +import NavigationInterfaces + +import UIKit + +public struct CoordinatorAssembly { + private let injector: DependencyInjector + + public init(injector: DependencyInjector) { + self.injector = injector + } + + @MainActor + public func register() { + injector.register(AppCoordinating.self) { (_, arg: UINavigationController) in + AppCoordinator(navigationController: arg) + } + + injector.register(HomeCoordinating.self) { (_, arg: (UINavigationController, Coordinator?)) in + let (navigationController, parentCoordinator) = arg + + return HomeCoordinator(parentCoordinator: parentCoordinator, + navigationController: navigationController) + } + + injector.register(DetailCoordinating.self) { (_, arg: (UINavigationController, Coordinator?, Exchange)) in + let (navigationController, parentCoordinator, exchange) = arg + + return DetailCoordinator(parentCoordinator: parentCoordinator, + navigationController: navigationController, + exchange: exchange) + } + + injector.register(DetailCoordinating.self) { (_, arg: (UINavigationController, Coordinator?, Int)) in + let (navigationController, parentCoordinator, exchangeId) = arg + + return DetailCoordinator(parentCoordinator: parentCoordinator, + navigationController: navigationController, + exchangeId: exchangeId) + } + } +} diff --git a/Projects/App/Sources/Assemblies/NetworkAssembly.swift.swift b/Projects/App/Sources/Assemblies/NetworkAssembly.swift.swift new file mode 100644 index 0000000..8c5ca5f --- /dev/null +++ b/Projects/App/Sources/Assemblies/NetworkAssembly.swift.swift @@ -0,0 +1,27 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import DependencyInjectionInterfaces +import NetworkingInterfaces +import Networking + +import Foundation + +public struct NetworkAssembly { + private let injector: DependencyInjector + + public init(injector: DependencyInjector) { + self.injector = injector + } + + public func register() { + injector.register(NetworkServiceProtocol.self) { _ in + NetworkService() + } + } +} diff --git a/Projects/App/Sources/Coordinator/AppCoordinator.swift b/Projects/App/Sources/Coordinator/AppCoordinator.swift new file mode 100644 index 0000000..a364e14 --- /dev/null +++ b/Projects/App/Sources/Coordinator/AppCoordinator.swift @@ -0,0 +1,35 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import DependencyInjectionInterfaces +import HomeInterfaces +import NavigationInterfaces +import UIKit + +protocol AppCoordinating: Coordinator {} + +@MainActor +final class AppCoordinator: AppCoordinating { + var parentCoordinator: Coordinator? + var children: [Coordinator] = [] + var navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func start() { + let resolver = SharedContainer.shared.resolver() + let arg: (UINavigationController, Coordinator?) = (navigationController, self) + + let homeCoordinator = resolver.resolve(HomeCoordinating.self, + argument: arg) + children.append(homeCoordinator) + homeCoordinator.start() + } +} diff --git a/Projects/App/Tests/Application/AppDelegateTests.swift b/Projects/App/Tests/Application/AppDelegateTests.swift new file mode 100644 index 0000000..85c87ef --- /dev/null +++ b/Projects/App/Tests/Application/AppDelegateTests.swift @@ -0,0 +1,108 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Testing +import DependencyInjectionInterfaces +import DetailInterfaces +import HomeInterfaces +import NavigationInterfaces +import NavigationTesting +import NetworkingInterfaces +import UIKit + +@testable import App + +@Suite +@MainActor +struct AppDelegateTests { + init() { + _ = AppDelegate() + } + + @Test("GIVEN AppDelegate initialize THEN it should set injector") + func setInjectot() async throws { + #expect(throws: Never.self) { + _ = SharedContainer.shared.resolver() + } + } + + @Test("GIVEN AppDelegate initialize THEN it SHOULD register DI Network") + func networkRegister() async throws { + #expect(throws: Never.self) { + let _: NetworkServiceProtocol = SharedContainer.shared.resolver().resolve() + } + } + + @Test("GIVEN AppDelegate initialize THEN it SHOULD register DI Network") + func appCoordinatorRegister() async throws { + #expect(throws: Never.self) { + let navigation = UINavigationController() + _ = SharedContainer + .shared + .resolver() + .resolve(AppCoordinating.self, argument: navigation) + } + } + + @Test("GIVEN AppDelegate initialize THEN it SHOULD register DI AppCoordinating") + func appCoordinatingRegister() async throws { + #expect(throws: Never.self) { + let navigation = UINavigationController() + _ = SharedContainer + .shared + .resolver() + .resolve(AppCoordinating.self, argument: navigation) + } + } + + @Test("GIVEN AppDelegate initialize THEN it SHOULD register DI HomeCoordinating") + func homeCoordinatorRegister() async throws { + #expect(throws: Never.self) { + let navigation = UINavigationController() + let parentCoordinator: Coordinator? = CoordinatorSpy(children: [], + navigationController: navigation) + + _ = SharedContainer + .shared + .resolver() + .resolve(HomeCoordinating.self, argument: (navigation, parentCoordinator)) + } + } + + @Test("GIVEN AppDelegate initialize THEN it SHOULD register DI DetailCoordinating with Exchange") + func detailCoordinatorRegister() async throws { + #expect(throws: Never.self) { + let navigation = UINavigationController() + let exchange = Exchange(summary: .init(id: 0, name: "exchange")) + + let parentCoordinator: Coordinator? = CoordinatorSpy(children: [], + navigationController: navigation) + + _ = SharedContainer + .shared + .resolver() + .resolve(DetailCoordinating.self, argument: (navigation, parentCoordinator, exchange)) + } + } + + @Test("GIVEN AppDelegate initialize THEN it SHOULD register DI DetailCoordinating with Exchange identifier") + func detailWithIdentifierCoordinatorRegister() async throws { + #expect(throws: Never.self) { + let navigation = UINavigationController() + let exchangeId: Int = 21 + + let parentCoordinator: Coordinator? = CoordinatorSpy(children: [], + navigationController: navigation) + + _ = SharedContainer + .shared + .resolver() + .resolve(DetailCoordinating.self, argument: (navigation, parentCoordinator, exchangeId)) + } + } +} diff --git a/Projects/App/Tests/Coordinator/AppCoordinatorTests.swift b/Projects/App/Tests/Coordinator/AppCoordinatorTests.swift new file mode 100644 index 0000000..eaa720a --- /dev/null +++ b/Projects/App/Tests/Coordinator/AppCoordinatorTests.swift @@ -0,0 +1,50 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// +// +import DependencyInjectionInterfaces +import DependencyInjectionTesting +import HomeInterfaces +import HomeTesting +import NavigationInterfaces +import NavigationTesting +import Testing +import UIKit + +@testable import App + +@MainActor +@Suite("AppCoordinator Tests") +struct AppCoordinatorTests { + + // MARK: - Start Tests + + @Test("Start resolves HomeCoordinating from container") + func startResolvesHomeCoordinator() { + // Given + let navigationController = UINavigationController() + let sut = AppCoordinator(navigationController: navigationController) + + let injectorSpy = DependencyInjectorSpy() + let homeCoordinatorSpy = HomeCoordinatingSpy( + children: [], + navigationController: navigationController + ) + + injectorSpy + .register(HomeCoordinating.self) { (_, arg: (UINavigationController, Coordinator?)) in + homeCoordinatorSpy + } + + SharedContainer.shared.setInjector(injectorSpy) + + sut.start() + + #expect(injectorSpy.calledMethods.contains(.resolveWithArgument)) + #expect(homeCoordinatorSpy.calledMethods.contains(.start)) + } +} diff --git a/Projects/App/UITests/ExchangesAppUITests.swift b/Projects/App/UITests/ExchangesAppUITests.swift new file mode 100644 index 0000000..ba4bdd9 --- /dev/null +++ b/Projects/App/UITests/ExchangesAppUITests.swift @@ -0,0 +1,41 @@ +// +// ExchangesAppUITests.swift +// ExchangesAppUITests +// +// Created by Vitor Conceicao on 06/02/26. +// + +import XCTest + +final class ExchangesAppUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/Projects/App/UITests/ExchangesAppUITestsLaunchTests.swift b/Projects/App/UITests/ExchangesAppUITestsLaunchTests.swift new file mode 100644 index 0000000..73c7b30 --- /dev/null +++ b/Projects/App/UITests/ExchangesAppUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// ExchangesAppUITestsLaunchTests.swift +// ExchangesAppUITests +// +// Created by Vitor Conceicao on 06/02/26. +// + +import XCTest + +final class ExchangesAppUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Projects/DependencyInjection/Interfaces/DependencyInjector.swift b/Projects/DependencyInjection/Interfaces/DependencyInjector.swift new file mode 100644 index 0000000..fe191e5 --- /dev/null +++ b/Projects/DependencyInjection/Interfaces/DependencyInjector.swift @@ -0,0 +1,21 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +public protocol DependencyInjector: DependencyResolver { + func register( + _ serviceType: Service.Type, + factory: @escaping (DependencyResolver) -> Service + ) + + func register( + _ serviceType: Service.Type, + factory: @escaping (DependencyResolver, Argument) -> Service + ) +} diff --git a/Projects/DependencyInjection/Interfaces/DependencyResolver.swift b/Projects/DependencyInjection/Interfaces/DependencyResolver.swift new file mode 100644 index 0000000..76d1c7f --- /dev/null +++ b/Projects/DependencyInjection/Interfaces/DependencyResolver.swift @@ -0,0 +1,18 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +public protocol DependencyResolver { + func resolve() -> Service + + func resolve( + _ serviceType: Service.Type, + argument: Argument + ) -> Service +} diff --git a/Projects/DependencyInjection/Interfaces/SharedContainer.swift b/Projects/DependencyInjection/Interfaces/SharedContainer.swift new file mode 100644 index 0000000..3fe908b --- /dev/null +++ b/Projects/DependencyInjection/Interfaces/SharedContainer.swift @@ -0,0 +1,29 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +public final class SharedContainer { + @MainActor public static let shared = SharedContainer() + + private var injector: DependencyInjector? + + private init() {} + + public func setInjector(_ injector: DependencyInjector) { + self.injector = injector + } + + public func resolver() -> DependencyResolver { + guard let injector = injector else { + fatalError("Dependency Error: Injector not set in SharedContainer.") + } + + return injector + } +} diff --git a/Projects/DependencyInjection/Project.swift b/Projects/DependencyInjection/Project.swift new file mode 100644 index 0000000..e4eb0aa --- /dev/null +++ b/Projects/DependencyInjection/Project.swift @@ -0,0 +1,12 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let moduleName = "DependencyInjection" + +let dependecies: [TargetDependency] = [ + .external(name: "Swinject", condition: nil), +] + +let project = Project.templateModule(named: moduleName, + targets: [.source,. interfaces, .test, .testing], + dependencies: dependecies) diff --git a/Projects/DependencyInjection/Sources/Injector.swift b/Projects/DependencyInjection/Sources/Injector.swift new file mode 100644 index 0000000..e9b4d93 --- /dev/null +++ b/Projects/DependencyInjection/Sources/Injector.swift @@ -0,0 +1,57 @@ +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import Swinject +import DependencyInjectionInterfaces + +public final class Injector: DependencyInjector { + private let container: Container + + public init() { + self.container = Container() + } + + public func register(_ serviceType: Service.Type, + factory: @escaping (DependencyResolver) -> Service) { + container.register(serviceType) { [unowned self] _ in + factory(self) + } + } + + public func register( + _ serviceType: Service.Type, + factory: @escaping (DependencyResolver, Argument) -> Service + ) { + container.register(serviceType) { [unowned self] (_, arg: Argument) in + factory(self, arg) + } + } + + public func resolve() -> Service { + guard let service = container.synchronize().resolve(Service.self) else { + fatalError("Dependency Error: No service of type \(Service.self) was registered.") + } + + return service + } + + public func resolve( + _ serviceType: Service.Type, + argument: Argument + ) -> Service { + guard let service = getSynchonizedResolver().resolve(Service.self, argument: argument) else { + fatalError("Dependency Error: No service of type \(Service.self) was registered.") + } + + return service + } + + private func getSynchonizedResolver() -> Resolver { + return container.synchronize() + } +} diff --git a/Projects/DependencyInjection/Testing/DependencyInjectorSpy.swift b/Projects/DependencyInjection/Testing/DependencyInjectorSpy.swift new file mode 100644 index 0000000..b5815e9 --- /dev/null +++ b/Projects/DependencyInjection/Testing/DependencyInjectorSpy.swift @@ -0,0 +1,72 @@ +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import DependencyInjectionInterfaces +import Foundation + +public final class DependencyInjectorSpy: DependencyInjector { + public init() {} + + public enum Method { + case resolve + case resolveWithArgument + } + + // Container to hold registered services and their factories + private var registeredServices: [String: Any] = [:] + private var registeredServicesWithArgument: [String: Any] = [:] + + public var calledMethods: [Method] = [] + + private func typeIdentifier(_ type: T.Type) -> String { + return String(reflecting: type) + } + + public func register(_ serviceType: Service.Type, + factory: @escaping (DependencyResolver) -> Service) { + let identifier = typeIdentifier(serviceType) + registeredServices[identifier] = factory + } + + public func register(_ serviceType: Service.Type, + factory: @escaping (DependencyResolver, Argument) -> Service) { + let identifier = typeIdentifier(serviceType) + registeredServicesWithArgument[identifier] = factory + } + + public func resolve() -> Service { + calledMethods.append(.resolve) + let identifier = typeIdentifier(Service.self) + + if let factory = registeredServices[identifier] as? (DependencyResolver) -> Service { + return factory(self) + } + + fatalError("No mock or registration found for \(identifier). Use stub(for:mock:) to configure a mock.") + } + + public func resolve(_ serviceType: Service.Type, + argument: Argument) -> Service { + calledMethods.append(.resolveWithArgument) + let identifier = typeIdentifier(serviceType) + + if let factory = registeredServicesWithArgument[identifier] as? (DependencyResolver, Argument) -> Service { + return factory(self, argument) + } + + fatalError("No mock or registration found for \(identifier). Use stub(for:mock:) to configure a mock.") + } + + // MARK: - Test Helpers + + /// Clear all registrations and mocks + public func reset() { + registeredServices.removeAll() + registeredServicesWithArgument.removeAll() + calledMethods.removeAll() + } +} diff --git a/Projects/DependencyInjection/Tests/Doubles/MockDependentService.swift b/Projects/DependencyInjection/Tests/Doubles/MockDependentService.swift new file mode 100644 index 0000000..18bb092 --- /dev/null +++ b/Projects/DependencyInjection/Tests/Doubles/MockDependentService.swift @@ -0,0 +1,17 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +protocol DependentService { + var testService: TestService { get } +} + +struct MockDependentService: DependentService { + let testService: TestService +} diff --git a/Projects/DependencyInjection/Tests/Doubles/MockService.swift b/Projects/DependencyInjection/Tests/Doubles/MockService.swift new file mode 100644 index 0000000..910005c --- /dev/null +++ b/Projects/DependencyInjection/Tests/Doubles/MockService.swift @@ -0,0 +1,24 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +protocol TestService { + var value: String { get } +} + +struct MockService: TestService { + let value: String = "test" +} + +struct MockServiceWithParameter: TestService { + var value: String = "test args" + + let parameter: String + let secondParameter: String +} diff --git a/Projects/DependencyInjection/Tests/InjectorTests.swift b/Projects/DependencyInjection/Tests/InjectorTests.swift new file mode 100644 index 0000000..b57cd69 --- /dev/null +++ b/Projects/DependencyInjection/Tests/InjectorTests.swift @@ -0,0 +1,60 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Testing +import DependencyInjection +import DependencyInjectionInterfaces + +@Suite("DependencyInjector Tests") +final class DependencyInjectorTests { + + @Test("Test synchronous registration and resolution") + func testSyncRegistrationAndResolution() { + let injector = Injector() + + injector.register(TestService.self) { _ in + MockService() + } + + let service: TestService = injector.resolve() + #expect(service.value == "test") + } + + + @Test("Test nested dependency resolution") + func testNestedResolution() { + let injector = Injector() + + injector.register(TestService.self) { _ in + MockService() + } + + injector.register(DependentService.self) { resolver in + MockDependentService(testService: resolver.resolve()) + } + + let service: DependentService = injector.resolve() + #expect(service.testService.value == "test") + } + + @Test("Test register and resolution with argument") + func testRegisterAndResolutionWithArgument() { + let injector = Injector() + let args = ("param1", "param2") + + injector.register(TestService.self) { (_, arg: (String, String)) in + let (param1, param2) = arg + return MockServiceWithParameter(parameter: param1, secondParameter: param2) + } + + let service: TestService = injector.resolve(TestService.self, + argument: args) + + #expect(service.value == "test args") + } +} diff --git a/Projects/DesignSystem/Project.swift b/Projects/DesignSystem/Project.swift new file mode 100644 index 0000000..506b511 --- /dev/null +++ b/Projects/DesignSystem/Project.swift @@ -0,0 +1,15 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let moduleName = "DesignSystem" + +let dependecies: [TargetDependency] = [] + +let testDependencies: [TargetDependency] = [ + .external(name: "SnapshotTesting") +] + +let project = Project.templateModule(named: moduleName, + targets: [.source, .test], + dependencies: dependecies, + testDependencies: testDependencies) diff --git a/Projects/DesignSystem/Sources/Components/ErrorView.swift b/Projects/DesignSystem/Sources/Components/ErrorView.swift new file mode 100644 index 0000000..ab2505f --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/ErrorView.swift @@ -0,0 +1,144 @@ +import UIKit + +public final class ErrorView: UIView { + private lazy var iconImageView: UIImageView = { + let config = UIImage.SymbolConfiguration(pointSize: 80, weight: .regular) + let image = UIImage(systemName: "exclamationmark.triangle.fill", + withConfiguration: config) + let imageView = UIImageView(image: image) + imageView.tintColor = .orange + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .title2) + label.textColor = .label + label.textAlignment = .center + label.numberOfLines = 1 + return label + }() + + private lazy var messageLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .callout) + label.textColor = .secondaryLabel + label.textAlignment = .center + label.numberOfLines = 0 + return label + }() + + private lazy var codeLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .footnote) + label.textColor = .tertiaryLabel + label.textAlignment = .right + label.numberOfLines = 1 + label.isHidden = true + return label + }() + + private lazy var retryButton: UIButton = { + let button = UIButton(type: .system) + let image = UIImage(systemName: "arrow.clockwise") + button.setImage(image, for: .normal) + button.setTitle("Try again", for: .normal) + button.titleLabel?.font = .preferredFont(forTextStyle: .body) + button.tintColor = .systemBlue + button.backgroundColor = .systemBlue.withAlphaComponent(0.15) + button.layer.cornerRadius = DSSpacings.medium.rawValue + button.contentEdgeInsets = UIEdgeInsets( + top: DSSpacings.medium.rawValue, + left: DSSpacings.xLarge.rawValue, + bottom: DSSpacings.medium.rawValue, + right: DSSpacings.xLarge.rawValue + ) + button.imageEdgeInsets = UIEdgeInsets(top: 0, + left: -DSSpacings.small.rawValue, + bottom: 0, + right: DSSpacings.small.rawValue) + button.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside) + return button + }() + + private lazy var textStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [titleLabel, messageLabel, codeLabel]) + stackView.axis = .vertical + stackView.alignment = .fill + stackView.spacing = DSSpacings.medium.rawValue + return stackView + }() + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [iconImageView, textStackView, retryButton]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = DSSpacings.xLarge.rawValue + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + public var retryAction: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func configure(title: String, + message: String, + code: String? = nil, + buttonText: String? = nil) { + titleLabel.text = title + messageLabel.text = message + + if let code { + codeLabel.text = "code: \(code)" + codeLabel.isHidden = false + } + + if let buttonText { + retryButton.setTitle(buttonText, for: .normal) + } + } + + @objc private func retryButtonTapped() { + retryAction?() + } +} + +extension ErrorView: ViewCode { + public func buildViewHierarch() { + addSubview(mainStackView) + } + + public func setUpConstraints() { + NSLayoutConstraint.activate([ + mainStackView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor), + mainStackView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), + mainStackView.centerYAnchor.constraint(equalTo: centerYAnchor), + mainStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DSSpacings.xLarge.rawValue), + mainStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -DSSpacings.xLarge.rawValue) + ]) + } +} + +#if DEBUG +import SwiftUI + +@available(iOS 17.0, *) +#Preview { + let view = ErrorView() + view.configure(title: "Failed to load Exchanges", + message: "Our server is experiencing problems.\nPlease try again.", + code: "500") + return view +} +#endif diff --git a/Projects/DesignSystem/Sources/Components/InfoCell.swift b/Projects/DesignSystem/Sources/Components/InfoCell.swift new file mode 100644 index 0000000..4820ee4 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/InfoCell.swift @@ -0,0 +1,222 @@ +import UIKit + +public final class InfoCell: UITableViewCell { + + public static let identifier = "InfoCell" + + // MARK: - Models + public enum State { + case loaded(InfoCellModel) + case partialLoaded(String) + case loading + } + + public struct InfoCellModel { + public let url: String + public let title: String + public let subtitle: String + public let detail: String + + public init(url: String, title: String, subtitle: String, detail: String) { + self.url = url + self.title = title + self.subtitle = subtitle + self.detail = detail + } + } + + // MARK: - UI Components + private lazy var containerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .secondarySystemGroupedBackground + view.layer.cornerRadius = 12 + view.clipsToBounds = true + return view + }() + + private lazy var iconImageView = makeImageView() + private lazy var titleLabel = makeLabel(font: .systemFont(ofSize: 17), color: .label) + private lazy var subtitleLabel = makeLabel(font: .systemFont(ofSize: 15), color: .systemGreen) + private lazy var detailLabel = makeLabel(font: .systemFont(ofSize: 15), color: .secondaryLabel) + + private lazy var chevronImageView: UIImageView = { + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) + let imageView = UIImageView(image: UIImage(systemName: "chevron.right", withConfiguration: config)) + imageView.tintColor = .tertiaryLabel + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var textStackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel, detailLabel]) + stack.axis = .vertical + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + return stack + }() + + // MARK: - Init + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder: NSCoder) { nil } + + public override func prepareForReuse() { + super.prepareForReuse() + [iconImageView, titleLabel, subtitleLabel, detailLabel].forEach { $0.setSkeleton(false) } + iconImageView.image = nil + titleLabel.text = nil + subtitleLabel.text = nil + detailLabel.text = nil + } + + // MARK: - Public Configuration + public func configure(state: State) { + [iconImageView, titleLabel, subtitleLabel, detailLabel].forEach { $0.setSkeleton(false) } + + switch state { + case let .loaded(model): + titleLabel.text = model.title + subtitleLabel.text = model.subtitle + detailLabel.text = model.detail + iconImageView.image = UIImage(systemName: "bahtsign.bank.building.fill") + loadImage(from: model.url) + + case let .partialLoaded(title): + iconImageView.image = UIImage(systemName: "bahtsign.bank.building.fill") + titleLabel.text = title + subtitleLabel.text = " "; subtitleLabel.setSkeleton(true) + detailLabel.text = " "; detailLabel.setSkeleton(true) + + case .loading: + iconImageView.setSkeleton(true, cornerRadius: 11) + titleLabel.text = " " + titleLabel.setSkeleton(true) + subtitleLabel.text = " " + subtitleLabel.setSkeleton(true) + detailLabel.text = " " + detailLabel.setSkeleton(true) + } + } +} + +// MARK: - Factory & ViewCode +private extension InfoCell { + func makeLabel(font: UIFont, color: UIColor) -> UILabel { + let label = UILabel() + label.font = font + label.textColor = color + label.translatesAutoresizingMaskIntoConstraints = false + return label + } + + func makeImageView() -> UIImageView { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 12 + imageView.backgroundColor = .secondarySystemFill + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + } + + 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 { iconImageView.image = image } + } + } catch {} + } + } +} + +extension InfoCell: ViewCode { + public func buildViewHierarch() { + contentView.addSubview(containerView) + [iconImageView, + textStackView, + chevronImageView].forEach { containerView.addSubview($0) } + } + + public func setUpConstraints() { + let horizontalPadding = DSSpacings.large.rawValue + let verticalPadding = DSSpacings.small.rawValue + + NSLayoutConstraint.activate([ + containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 84), + + 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.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + iconImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, + constant: horizontalPadding), + iconImageView.widthAnchor.constraint(equalToConstant: 44), + iconImageView.heightAnchor.constraint(equalToConstant: 44), + + textStackView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + textStackView.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, + constant: 12), + textStackView.trailingAnchor.constraint(equalTo: chevronImageView.leadingAnchor, + constant: -12), + textStackView.topAnchor.constraint(greaterThanOrEqualTo: containerView.topAnchor, + constant: 12), + textStackView.bottomAnchor.constraint(lessThanOrEqualTo: containerView.bottomAnchor, + constant: -12), + + chevronImageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + chevronImageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, + constant: -horizontalPadding), + chevronImageView.widthAnchor.constraint(equalToConstant: 14) + ]) + } + + public func additionalConfiguration() { + selectionStyle = .none + backgroundColor = .clear + contentView.backgroundColor = .clear + } +} + +#if DEBUG +import SwiftUI + +@available(iOS 17.0, *) +#Preview("Loaded") { + let view = InfoCell() + view.configure(state: .loaded(.init( + url: "", + title: "Binance", + subtitle: "Vol: $1234.23", + detail: "Date launched: 12/03/2025" + ))) + return view +} + +@available(iOS 17.0, *) +#Preview("Partial Loaded") { + let view = InfoCell() + view.configure(state: .partialLoaded("Binance")) + return view +} + +@available(iOS 17.0, *) +#Preview("Loading") { + let view = InfoCell() + view.configure(state: .loading) + return view +} +#endif diff --git a/Projects/DesignSystem/Sources/Components/Skeleton.swift b/Projects/DesignSystem/Sources/Components/Skeleton.swift new file mode 100644 index 0000000..88030b6 --- /dev/null +++ b/Projects/DesignSystem/Sources/Components/Skeleton.swift @@ -0,0 +1,37 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import UIKit + +public extension UIView { + func setSkeleton(_ show: Bool, cornerRadius: CGFloat = 5, width: CGFloat? = nil) { + subviews.first(where: { $0.tag == 999 })?.removeFromSuperview() + + if show { + let skeleton = UIView() + skeleton.backgroundColor = .secondarySystemFill + skeleton.layer.cornerRadius = cornerRadius + skeleton.tag = 999 + skeleton.translatesAutoresizingMaskIntoConstraints = false + + addSubview(skeleton) + + NSLayoutConstraint.activate([ + skeleton.topAnchor.constraint(equalTo: topAnchor), + skeleton.leadingAnchor.constraint(equalTo: leadingAnchor), + skeleton.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + NSLayoutConstraint.activate([ + width != nil + ? skeleton.widthAnchor.constraint(equalToConstant: width!) + : skeleton.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + } + } +} diff --git a/Projects/DesignSystem/Sources/Constants/Spacings.swift b/Projects/DesignSystem/Sources/Constants/Spacings.swift new file mode 100644 index 0000000..8ea89a7 --- /dev/null +++ b/Projects/DesignSystem/Sources/Constants/Spacings.swift @@ -0,0 +1,20 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +public enum DSSpacings: CGFloat { + case none = 0 + case xSmall = 2 + case small = 4 + case medium = 8 + case large = 16 + case xLarge = 32 + case xxLarge = 64 + case xxxLarge = 128 +} diff --git a/Projects/DesignSystem/Sources/Protocols/ViewCode.swift b/Projects/DesignSystem/Sources/Protocols/ViewCode.swift new file mode 100644 index 0000000..127def2 --- /dev/null +++ b/Projects/DesignSystem/Sources/Protocols/ViewCode.swift @@ -0,0 +1,28 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +@MainActor +public protocol ViewCode: AnyObject { + func buildViewHierarch() + func setUpConstraints() + func additionalConfiguration() +} + +public extension ViewCode { + func setupView() { + buildViewHierarch() + setUpConstraints() + additionalConfiguration() + } + + func buildViewHierarch() {} + func setUpConstraints() {} + func additionalConfiguration() {} +} diff --git a/Projects/DesignSystem/Tests/Components/ErrorViewSnapshotTest.swift b/Projects/DesignSystem/Tests/Components/ErrorViewSnapshotTest.swift new file mode 100644 index 0000000..b1e158d --- /dev/null +++ b/Projects/DesignSystem/Tests/Components/ErrorViewSnapshotTest.swift @@ -0,0 +1,49 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import SnapshotTesting +import Testing + +@testable import DesignSystem + +@MainActor +@Suite() +struct ErrorViewSnapshotTest { + @Test("GIVEN ErrorView THEN it should configure with default values") + func testConfigureDefaultValues() throws { + let sut = makeSut(title: "Failed to load Exchanges", + message: "Our server is experiencing problems.\nPlease try again.") + + assertSnapshot(of: sut, as: .image) + } + + @Test("GIVEN ErrorView THEN it should configure with code and buttons") + func testConfigureCode() throws { + let sut = makeSut(title: "Failed to load Exchanges", + message: "Our server is experiencing problems.\nPlease try again.", + code: "500") + + assertSnapshot(of: sut, as: .image) + } + + func makeSut(title: String, + message: String, + code: String? = nil, + buttonText: String? = nil) -> ErrorView { + let sut = ErrorView() + sut.frame = CGRect(x: 0, y: 0, width: 402, height: 874) + sut.backgroundColor = .white + sut.configure(title: title, + message: message, + code: code, + buttonText: buttonText) + + return sut + } +} diff --git a/Projects/DesignSystem/Tests/Components/__Snapshots__/ErrorViewSnapshotTest/testConfigureCode.1.png b/Projects/DesignSystem/Tests/Components/__Snapshots__/ErrorViewSnapshotTest/testConfigureCode.1.png new file mode 100644 index 0000000..f3aefe0 Binary files /dev/null and b/Projects/DesignSystem/Tests/Components/__Snapshots__/ErrorViewSnapshotTest/testConfigureCode.1.png differ diff --git a/Projects/DesignSystem/Tests/Components/__Snapshots__/ErrorViewSnapshotTest/testConfigureDefaultValues.1.png b/Projects/DesignSystem/Tests/Components/__Snapshots__/ErrorViewSnapshotTest/testConfigureDefaultValues.1.png new file mode 100644 index 0000000..9143b21 Binary files /dev/null and b/Projects/DesignSystem/Tests/Components/__Snapshots__/ErrorViewSnapshotTest/testConfigureDefaultValues.1.png differ diff --git a/Projects/Detail/Interfaces/DetailCoordinating.swift b/Projects/Detail/Interfaces/DetailCoordinating.swift new file mode 100644 index 0000000..0360436 --- /dev/null +++ b/Projects/Detail/Interfaces/DetailCoordinating.swift @@ -0,0 +1,12 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import NavigationInterfaces +import UIKit + +public protocol DetailCoordinating: Coordinator {} diff --git a/Projects/Detail/Project.swift b/Projects/Detail/Project.swift new file mode 100644 index 0000000..dd70811 --- /dev/null +++ b/Projects/Detail/Project.swift @@ -0,0 +1,32 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let moduleName = "Detail" + +let dependecies: [TargetDependency] = [ + .project(target: "DependencyInjectionInterfaces", + path: "../DependencyInjection"), + .project(target: "DesignSystem", + path: "../DesignSystem"), + .project(target: "HomeInterfaces", + path: "../Home"), + .external(name: "NetworkingInterfaces"), +] + +let interfaceDependecies: [TargetDependency] = [ + .project(target: "NavigationInterfaces", + 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 new file mode 100644 index 0000000..91962a5 --- /dev/null +++ b/Projects/Detail/Sources/Coordinator/DetailCoordinator.swift @@ -0,0 +1,64 @@ +import UIKit +import DetailInterfaces +import DependencyInjectionInterfaces +import HomeInterfaces +import NavigationInterfaces +import NetworkingInterfaces + +@MainActor +public final class DetailCoordinator: DetailCoordinating { + // MARK: - Properties + + public weak var parentCoordinator: Coordinator? + public var children: [Coordinator] = [] + public var navigationController: UINavigationController + + private var exchange: Exchange? + private var exchangeId: Int? + + // MARK: - Initialization + + public init(parentCoordinator: Coordinator?, + navigationController: UINavigationController, + exchange: Exchange) { + self.parentCoordinator = parentCoordinator + self.navigationController = navigationController + self.exchange = exchange + } + + public init(parentCoordinator: Coordinator?, + navigationController: UINavigationController, + exchangeId: Int) { + self.parentCoordinator = parentCoordinator + self.navigationController = navigationController + self.exchangeId = exchangeId + } + + // 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..f4d5289 --- /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: StatusResponse = try decodeResponse(data) + throw DetailServiceError.network(error.status) + } + } + + 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..8da7f34 --- /dev/null +++ b/Projects/Detail/Sources/View/AssetTableViewCell.swift @@ -0,0 +1,138 @@ +// +// +// 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.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + formatter.usesGroupingSeparator = true + 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..d8412e0 --- /dev/null +++ b/Projects/Detail/Sources/View/DetailHeaderView.swift @@ -0,0 +1,240 @@ +// +// +// 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) + if #available(iOS 16.0, *) { + button.setImage(UIImage(systemName: "bird.fill", withConfiguration: config), for: .normal) + } else { + button.setImage(UIImage(systemName: "bubble.left", 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/Testing/Coordinator/DetailCoordinatingSpy.swift b/Projects/Detail/Testing/Coordinator/DetailCoordinatingSpy.swift new file mode 100644 index 0000000..7ee637a --- /dev/null +++ b/Projects/Detail/Testing/Coordinator/DetailCoordinatingSpy.swift @@ -0,0 +1,27 @@ +import UIKit +import DetailInterfaces +import NavigationInterfaces + +@MainActor +public final class DetailCoordinatingSpy: DetailCoordinating { + public enum Method: Equatable { + case start + } + + public var calledMethods: [Method] = [] + public var parentCoordinator: Coordinator? + public var children: [Coordinator] = [] + public var navigationController: UINavigationController + + public init(parentCoordinator: Coordinator? = nil, + children: [Coordinator], + navigationController: UINavigationController) { + self.parentCoordinator = parentCoordinator + self.children = children + self.navigationController = navigationController + } + + public func start() { + calledMethods.append(.start) + } +} 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/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..bd1fbfd --- /dev/null +++ b/Projects/Detail/Tests/Service/DetailServiceTests.swift @@ -0,0 +1,148 @@ +// +// +// 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 = """ + { + "status": { + "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/Coordinator/HomeCoordinating.swift b/Projects/Home/Interfaces/Coordinator/HomeCoordinating.swift new file mode 100644 index 0000000..651ad83 --- /dev/null +++ b/Projects/Home/Interfaces/Coordinator/HomeCoordinating.swift @@ -0,0 +1,12 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import NavigationInterfaces +import UIKit + +public protocol HomeCoordinating: Coordinator {} diff --git a/Projects/Home/Interfaces/Model/Exchange.swift b/Projects/Home/Interfaces/Model/Exchange.swift new file mode 100644 index 0000000..bf63b73 --- /dev/null +++ b/Projects/Home/Interfaces/Model/Exchange.swift @@ -0,0 +1,61 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +public struct Exchange: Equatable, Identifiable { + public let id: Int + public let name: String + public let description: String? + public let logo: String + public let spotVolumeUsd: Double? + public let makerFee: Double + public let takerFee: Double + public let dateLaunched: String + public let websiteUrl: String? + public let twitterUrl: String? + + public let isLoadingDetails: Bool + + public 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 + } + + public 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/Interfaces/Model/ExchangeSummary.swift b/Projects/Home/Interfaces/Model/ExchangeSummary.swift new file mode 100644 index 0000000..c38c402 --- /dev/null +++ b/Projects/Home/Interfaces/Model/ExchangeSummary.swift @@ -0,0 +1,19 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +public struct ExchangeSummary: Sendable, Equatable, Codable { + public let id: Int + public let name: String + + public init(id: Int, name: String) { + self.id = id + self.name = name + } +} diff --git a/Projects/Home/Interfaces/Model/Status.swift b/Projects/Home/Interfaces/Model/Status.swift new file mode 100644 index 0000000..b5fe4be --- /dev/null +++ b/Projects/Home/Interfaces/Model/Status.swift @@ -0,0 +1,43 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation + +public struct StatusResponse: Equatable, Sendable, Codable { + public let status: Status +} + +public struct Status: Equatable, Sendable { + public let timestamp: String + public let errorCode: Int? + public let errorMessage: String? + public let elapsed: Int + public let creditCount: Int + + public init(timestamp: String, + errorCode: Int? = nil, + errorMessage: String? = nil, + elapsed: Int, + creditCount: Int) { + self.timestamp = timestamp + self.errorCode = errorCode + self.errorMessage = errorMessage + self.elapsed = elapsed + self.creditCount = creditCount + } +} + +extension Status: Codable { + public 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/Project.swift b/Projects/Home/Project.swift new file mode 100644 index 0000000..9db5cf3 --- /dev/null +++ b/Projects/Home/Project.swift @@ -0,0 +1,34 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let moduleName = "Home" + +let dependecies: [TargetDependency] = [ + .project(target: "DependencyInjectionInterfaces", + path: "../DependencyInjection"), + .project(target: "DesignSystem", + path: "../DesignSystem"), + .project(target: "DetailInterfaces", + path: "../Detail"), + .external(name: "NetworkingInterfaces"), +] + +let interfaceDependecies: [TargetDependency] = [ + .project(target: "NavigationInterfaces", + path: "../Navigation"), +] + +let testsDependecies: [TargetDependency] = [ + .project(target: "DependencyInjectionTesting", + path: "../DependencyInjection"), + .project(target: "DetailTesting", + path: "../Detail"), + .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/Home/Sources/Coordinator/HomeCoordinator.swift b/Projects/Home/Sources/Coordinator/HomeCoordinator.swift new file mode 100644 index 0000000..5c3e911 --- /dev/null +++ b/Projects/Home/Sources/Coordinator/HomeCoordinator.swift @@ -0,0 +1,49 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import DependencyInjectionInterfaces +import DetailInterfaces +import HomeInterfaces +import NavigationInterfaces +import NetworkingInterfaces +import UIKit + +public final class HomeCoordinator: HomeCoordinating { + public var parentCoordinator: (Coordinator)? + public var children: [Coordinator] = [] + public var navigationController: UINavigationController + + public init(parentCoordinator: Coordinator? = nil, + navigationController: UINavigationController) { + self.parentCoordinator = parentCoordinator + self.navigationController = navigationController + } + + 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) { + let resolver = SharedContainer.shared.resolver() + let arg: (UINavigationController, Coordinator?, Exchange) = (navigationController, self, exchange) + + let detailCoordinator = resolver.resolve(DetailCoordinating.self, + argument: arg) + + children.append(detailCoordinator) + detailCoordinator.start() + } +} 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..08d809e --- /dev/null +++ b/Projects/Home/Sources/Model/ExchangeDetailResponse.swift @@ -0,0 +1,15 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces + +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..8bdc7a5 --- /dev/null +++ b/Projects/Home/Sources/Model/ExchangeResponse.swift @@ -0,0 +1,15 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces + +struct ExchangeResponse: Codable { + let status: Status + let data: [T] +} 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..3b20c20 --- /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, String?) +} diff --git a/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift b/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift new file mode 100644 index 0000000..d541887 --- /dev/null +++ b/Projects/Home/Sources/Protocols/HomeServiceProtocol.swift @@ -0,0 +1,15 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import HomeInterfaces +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..a5ef733 --- /dev/null +++ b/Projects/Home/Sources/Protocols/HomeViewModelCoordinatorDelegate.swift @@ -0,0 +1,15 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces + +@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..7f67cca --- /dev/null +++ b/Projects/Home/Sources/Protocols/HomeViewModelProtocol.swift @@ -0,0 +1,23 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import HomeInterfaces +import Foundation + +@MainActor +protocol HomeViewModelProtocol: AnyObject { + var delegate: HomeViewModelDelegate? { get set } + var state: HomeViewState { get } + var numberOfItems: Int { get } + + 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/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..041d67b --- /dev/null +++ b/Projects/Home/Sources/Service/HomeService.swift @@ -0,0 +1,55 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces +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: StatusResponse = try decodeResponse(data) + throw ServiceError.network(error.status) + } + } + + 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..c0bf436 --- /dev/null +++ b/Projects/Home/Sources/View/HomeViewController.swift @@ -0,0 +1,170 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import DesignSystem +import UIKit + +class HomeViewController: UIViewController { + + private let viewModel: HomeViewModelProtocol + + private lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + tableView.register(InfoCell.self, forCellReuseIdentifier: InfoCell.identifier) + tableView.isHidden = true + tableView.delegate = self + tableView.dataSource = self + tableView.separatorStyle = .none + return tableView + }() + + 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 errorView: ErrorView = { + let view = ErrorView() + view.translatesAutoresizingMaskIntoConstraints = false + view.isHidden = true + return view + }() + + 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" + setupView() + + tableView.tableFooterView = footerSpinner + viewModel.delegate = self + viewModel.loadData() + } +} + +extension HomeViewController: ViewCode { + func buildViewHierarch() { + view.addSubview(tableView) + view.addSubview(errorView) + } + + func setUpConstraints() { + 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), + + errorView.topAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.topAnchor), + errorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + errorView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + errorView.bottomAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.bottomAnchor), + errorView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + func additionalConfiguration() { + errorView.retryAction = { [weak self] in + self?.viewModel.loadData() + } + } +} + +// MARK: - Delegate (Updates) +extension HomeViewController: HomeViewModelDelegate { + func didUpdateState(_ state: HomeViewState) { + switch state { + case .loading: + tableView.isHidden = false + errorView.isHidden = true + case .loadingMore: + footerSpinner.startAnimating() + case .loaded: + footerSpinner.stopAnimating() + errorView.isHidden = true + tableView.isHidden = false + tableView.reloadData() + case .empty: + errorView.configure(title: "No exchanges found..", + message: "Try again later or check your connection.") + errorView.isHidden = false + case let .error(msg, code): + tableView.isHidden = true + footerSpinner.stopAnimating() + if viewModel.numberOfItems == 0 { + errorView.configure(title: "Failed to load Exchanges", + message: msg, + code: code) + errorView.isHidden = false + } + } + } +} + +// MARK: - TableView (Pagination & Cells) +extension HomeViewController: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch viewModel.state { + case .loading: + return 3 + default: + return viewModel.numberOfItems + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: InfoCell.identifier, + for: indexPath) as? InfoCell else { + return UITableViewCell() + } + + switch viewModel.state { + case .loading: + cell.configure(state: .loading) + case .loaded: + let item = viewModel.item(at: indexPath.row) + if item.isLoadingDetails { + cell.configure(state: .partialLoaded(item.name)) + } else { + let volPrice = "Vol: \(viewModel.formatPrice(item.spotVolumeUsd ?? 0.0))" + let dateLaunched = "Date launched: \(viewModel.formatDate(item.dateLaunched))" + cell.configure(state: .loaded(.init(url: item.logo, + title: item.name, + subtitle: volPrice, + detail: dateLaunched))) + } + default: + return UITableViewCell() + } + + 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..79b5190 --- /dev/null +++ b/Projects/Home/Sources/ViewModel/HomeViewModel.swift @@ -0,0 +1,162 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import HomeInterfaces +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 { + let serviceError = error as? ServiceError + let defaultMessage = "Failed to Load data. Press try again later or check your connection." + let message: String + var code: String? + switch serviceError { + case .decodeFail, .none: + message = defaultMessage + case .network(let status): + message = status.errorMessage ?? defaultMessage + if let statusCode = status.errorCode { + code = String(statusCode) + } + } + state = .error(message, code) + } else { + 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) + } + + 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/Testing/Coordinator/HomeCoordinatingSpy.swift b/Projects/Home/Testing/Coordinator/HomeCoordinatingSpy.swift new file mode 100644 index 0000000..b4c00d3 --- /dev/null +++ b/Projects/Home/Testing/Coordinator/HomeCoordinatingSpy.swift @@ -0,0 +1,34 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import HomeInterfaces +import NavigationInterfaces +import UIKit + +public final class HomeCoordinatingSpy: HomeCoordinating { + public enum Method: Equatable { + case start + } + + public var calledMethods: [Method] = [] + public var parentCoordinator: Coordinator? + public var children: [Coordinator] = [] + public var navigationController: UINavigationController + + public init(parentCoordinator: Coordinator? = nil, + children: [Coordinator], + navigationController: UINavigationController) { + self.parentCoordinator = parentCoordinator + self.children = children + self.navigationController = navigationController + } + + public func start() { + calledMethods.append(.start) + } +} diff --git a/Projects/Home/Tests/Coordinator/HomeCoordinatorTests.swift b/Projects/Home/Tests/Coordinator/HomeCoordinatorTests.swift new file mode 100644 index 0000000..e789bdd --- /dev/null +++ b/Projects/Home/Tests/Coordinator/HomeCoordinatorTests.swift @@ -0,0 +1,87 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import DependencyInjectionInterfaces +import DependencyInjectionTesting +import DetailInterfaces +import DetailTesting +import HomeInterfaces +import NavigationInterfaces +import NavigationTesting +import NetworkingInterfaces +import Testing +import UIKit + +@testable import Home + +@MainActor +@Suite +struct HomeCoordinatorTestsTests { + + // MARK: - Start Tests + + @Test("GIVEN HomeCoordinator WHEN start is called THEN push home") + func startNavigateToHome() { + let (sut, doubles) = makeSut() + doubles.injectorSpy + .register(NetworkServiceProtocol.self) { _ in + doubles.networkSpy + } + + sut.start() + + #expect(doubles.injectorSpy.calledMethods == [.resolve]) + #expect(doubles.navigationController.calledMethods == [.pushViewController]) + } + + @Test("GIVEN HomeCoordinator WHEN navigateToDetails is called THEN start details") + func avigateToDetailsNavitageToDetails() { + let (sut, doubles) = makeSut() + let navigationController = doubles.navigationController + let detailsCoordinatingSpy = DetailCoordinatingSpy(children: [], + navigationController: navigationController) + + doubles.injectorSpy + .register(DetailCoordinating.self) { (_, arg: (UINavigationController, Coordinator?, Exchange)) in + detailsCoordinatingSpy + } + + sut.navigateToDetails(of: .init(summary: .init(id: 1, name: "")) ) + + #expect(doubles.injectorSpy.calledMethods == [.resolveWithArgument]) + #expect(detailsCoordinatingSpy.calledMethods == [.start]) + } +} + +extension HomeCoordinatorTestsTests { + typealias SutAndDoubles = ( + sut: HomeCoordinator, + doubles: ( + networkSpy: NetworkServiceProtocolSpy, + coordinatorSpy: CoordinatorSpy, + navigationController: NavigationControllerSpy, + injectorSpy: DependencyInjectorSpy + ) + ) + + func makeSut() -> SutAndDoubles { + let navigationController = NavigationControllerSpy() + let coordinatorSpy = CoordinatorSpy(navigationController: navigationController) + let network = NetworkServiceProtocolSpy() + let injectorSpy = DependencyInjectorSpy() + SharedContainer.shared.setInjector(injectorSpy) + + let sut = HomeCoordinator(parentCoordinator: coordinatorSpy, + navigationController: navigationController) + + return (sut, (network, + coordinatorSpy, + navigationController, + injectorSpy)) + } +} diff --git a/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift b/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift new file mode 100644 index 0000000..a98bf23 --- /dev/null +++ b/Projects/Home/Tests/Doubles/HomeServiceProtocolSpy.swift @@ -0,0 +1,48 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces + +@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..851c331 --- /dev/null +++ b/Projects/Home/Tests/Doubles/HomeViewModelCoordinatorDelegateSpy.swift @@ -0,0 +1,25 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces + +@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..242b9e7 --- /dev/null +++ b/Projects/Home/Tests/Doubles/HomeViewModelSpy.swift @@ -0,0 +1,53 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces + +@testable import Home + +@MainActor +final class HomeViewModelSpy: HomeViewModelProtocol { + var state: HomeViewState = .loading + + enum Method: Equatable { + case loadData + case item + case didSelectRow + case formatPrice + case formatDate + } + + 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) + } + + func formatPrice(_ value: Double) -> String { + calledMethods.append(.formatPrice) + return "" + } + + func formatDate(_ date: String) -> String { + calledMethods.append(.formatDate) + return "" + } +} 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..81e6cd8 --- /dev/null +++ b/Projects/Home/Tests/Service/HomeServiceTests.swift @@ -0,0 +1,191 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import HomeInterfaces +import Foundation +import Testing + +@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 = """ + { + "status": { + "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 = """ + { + "status": { + "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/View/HomeViewControllerTests.swift b/Projects/Home/Tests/View/HomeViewControllerTests.swift new file mode 100644 index 0000000..b7044cf --- /dev/null +++ b/Projects/Home/Tests/View/HomeViewControllerTests.swift @@ -0,0 +1,125 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import HomeInterfaces +import Testing +import UIKit +import DesignSystem + +@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 in loading state WHEN tableView numberOfRows is called THEN returns 3") + func testTableViewNumberOfRowsLoading() throws { + let (sut, viewModelSpy) = makeSut() + viewModelSpy.state = .loading + sut.loadView() + sut.viewDidLoad() + + let numberOfRows = sut.tableView(UITableView(), numberOfRowsInSection: 0) + + #expect(numberOfRows == 3) + } + + @Test("GIVEN viewController in loaded state WHEN tableView numberOfRows is called THEN returns viewModel numberOfItems") + func testTableViewNumberOfRowsLoaded() throws { + let (sut, viewModelSpy) = makeSut() + viewModelSpy.state = .loaded + viewModelSpy.numberOfItems = 5 + sut.loadView() + sut.viewDidLoad() + + let numberOfRows = sut.tableView(UITableView(), numberOfRowsInSection: 0) + + #expect(numberOfRows == 5) + } + + @Test("GIVEN viewController in loading state WHEN tableView cellForRow is called THEN returns InfoCell in loading state") + func testTableViewCellForRowLoading() throws { + let (sut, viewModelSpy) = makeSut() + viewModelSpy.state = .loading + sut.loadView() + sut.viewDidLoad() + + let tableView = UITableView() + tableView.register(InfoCell.self, forCellReuseIdentifier: InfoCell.identifier) + + let cell = sut.tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0)) + + #expect(cell is InfoCell) + } + + @Test("GIVEN viewController in loaded state WHEN tableView cellForRow is called THEN returns configured InfoCell") + func testTableViewCellForRowLoaded() 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.state = .loaded + viewModelSpy.itemsToReturn = [exchange] + viewModelSpy.numberOfItems = 1 + + sut.loadView() + sut.viewDidLoad() + + let tableView = UITableView() + tableView.register(InfoCell.self, forCellReuseIdentifier: InfoCell.identifier) + + let cell = sut.tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0)) + + #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") + 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..680a6a9 --- /dev/null +++ b/Projects/Home/Tests/ViewModel/HomeViewModelTests.swift @@ -0,0 +1,217 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import Foundation +import HomeInterfaces +import Testing + +@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 messageError = "Failed to Load data. Press try again later or check your connection." + 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(messageError, nil))]) + } + + @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 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() + + 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)) + } +} diff --git a/Projects/Navigation/Interfaces/Coordinator.swift b/Projects/Navigation/Interfaces/Coordinator.swift new file mode 100644 index 0000000..27cfa90 --- /dev/null +++ b/Projects/Navigation/Interfaces/Coordinator.swift @@ -0,0 +1,18 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import UIKit + +@MainActor +public protocol Coordinator: AnyObject { + var parentCoordinator: Coordinator? { get set } + var children: [Coordinator] { get set } + var navigationController: UINavigationController { get set } + + func start() +} diff --git a/Projects/Navigation/Project.swift b/Projects/Navigation/Project.swift new file mode 100644 index 0000000..e594ae8 --- /dev/null +++ b/Projects/Navigation/Project.swift @@ -0,0 +1,10 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let moduleName = "Navigation" + +let dependecies: [TargetDependency] = [] + +let project = Project.templateModule(named: moduleName, + targets: [.interfaces, .testing], + dependencies: dependecies) diff --git a/Projects/Navigation/Testing/CoordinatorSpy.swift b/Projects/Navigation/Testing/CoordinatorSpy.swift new file mode 100644 index 0000000..0aa20d9 --- /dev/null +++ b/Projects/Navigation/Testing/CoordinatorSpy.swift @@ -0,0 +1,33 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import NavigationInterfaces +import UIKit + +public final class CoordinatorSpy: Coordinator { + public enum Method { + case start + } + + public var calledMethods: [Method] = [] + public var parentCoordinator: Coordinator? + public var children: [Coordinator] = [] + public var navigationController: UINavigationController + + public init(parentCoordinator: Coordinator? = nil, + children: [Coordinator] = [], + navigationController: UINavigationController) { + self.parentCoordinator = parentCoordinator + self.children = children + self.navigationController = navigationController + } + + public func start() { + calledMethods.append(.start) + } +} diff --git a/Projects/Navigation/Testing/NavigationControllerSpy.swift b/Projects/Navigation/Testing/NavigationControllerSpy.swift new file mode 100644 index 0000000..01756ee --- /dev/null +++ b/Projects/Navigation/Testing/NavigationControllerSpy.swift @@ -0,0 +1,32 @@ +// +// +// Created by Vitor Conceicao. +// +// github.com/vitor-rc1 +// +// + +import UIKit + +public final class NavigationControllerSpy: UINavigationController { + public enum Method: Equatable { + case pushViewController + case present + } + + public private(set) var calledMethods: [Method] = [] + + public var pushViewControllerPassed: UIViewController? + public override func pushViewController(_ viewController: UIViewController, animated: Bool) { + calledMethods.append(.pushViewController) + self.pushViewControllerPassed = viewController + } + + public var presentViewControllerPassed: UIViewController? + public override func present(_ viewControllerToPresent: UIViewController, + animated flag: Bool, + completion: (() -> Void)? = nil) { + calledMethods.append(.present) + self.presentViewControllerPassed = viewControllerToPresent + } +} diff --git a/README.md b/README.md index e9bf15b..d4cf033 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,318 @@ -# exchanges-app -iOS app to list exchanges list from CoinMaketCap Api +# Exchanges App +A simple iOS application that displays a list of cryptocurrency exchanges and their details using the CoinMarketCap API. +This app have support to iOS 15+ and is built using Swift 6, UIKit, and follows the MVVM-C and TMA architecture. + +[Figma Design](https://www.figma.com/design/OWVBNp9S0F6mbiXfdwOW9G/Exchanges-app?node-id=0-1&t=AvN5N9ABLPwF8tJy-1) + +--- + +## 📌 Summary +- [Exchanges App](#exchanges-app) + - [📌 Summary](#-summary) +- [Project Setup Guide](#project-setup-guide) + - [Requirements to setup the project](#requirements-to-setup-the-project) + - [Installation Steps](#installation-steps) + - [Dependencies of the project](#dependencies-of-the-project) + - [About the project structure](#about-the-project-structure) + - [Preview](#preview) + - [Exchange List](#exchange-list) + - [Exchange Detail](#exchange-detail) + - [Error](#error) +- [System Design](#system-design) + - [Functional requirements](#functional-requirements) + - [Non-functional requirements](#non-functional-requirements) + - [Data Entities Definitions](#data-entities-definitions) + - [Entity Relationship Diagram](#entity-relationship-diagram) + - [API Endpoints](#api-endpoints) + - [1. List Exchanges (Mapping)](#1-list-exchanges-mapping) + - [2. Exchange Metadata](#2-exchange-metadata) + - [3. Exchange Assets (Holdings)](#3-exchange-assets-holdings) + - [Error Handling \& Status Codes](#error-handling--status-codes) + - [High-Level Design (MVVM-C)](#high-level-design-mvvm-c) + - [Architectural Components](#architectural-components) + +--- + +# Project Setup Guide +## Requirements to setup the project +To set up the project, you will need the following tools installed on your machine: +- [Xcode 26.2+](https://xcodereleases.com/) +- [Homebrew](https://brew.sh/) (for installing dependencies) +- [RubyGems 3.6+](https://rubygems.org/) (for installing dependencies via Bundler) + +## Installation Steps +1. Create API Key: + - Sign up for a free account on [CoinMarketCap API](https://coinmarketcap.com/api/) + - Generate an API key from the dashboard. +2. create .env file in the root of the project and add your CoinMarketCap API key: +``` + TUIST_CM_API_BASE_URL=https://pro-api.coinmarketcap.com + TUIST_CM_API_KEY=ADD_COINMARKETCAP_API_KEY_HERE +``` +3. Install dependencies: + - Open Terminal and navigate to the project directory. + - Run `make local-setup` to install the required project dependencies. + - Run `make generate-project` to generate the project workspace. + +## Dependencies of the project +The project uses the following dependencies: +- [Tuist](https://tuist.io/) for project generation and management. +- [Fastlane](https://fastlane.tools/) for automating build and release processes. +- [SwiftLint](https://github.com/realm/SwiftLint) for code linting and formatting. +- [xcbeautify](https://github.com/tuist/xcbeautify) for beautifying Fastlane build output. +- [Slather](https://github.com/SlatherOrg/slather) for code coverage reports. +- [Danger](https://danger.systems/) for automated code review and quality checks. +- [networkin-package](https://github.com/vitor-rc1/networking-package) SPM parckge for abstracting network layer and API calls. This package was created by me and is used in this project to handle all network requests and responses, providing a clean and modular way to interact with the CoinMarketCap API. +- [Swinject](https://github.com/Swinject/Swinject) for dependency injection, allowing for better separation of concerns and easier testing. +- [Snapshot Testing](https://github.com/pointfreeco/swift-snapshot-testing) for testing the UI components and ensuring they render correctly across different states and configurations. + +## About the project structure +The project is organized into the following main directories: +- `Projects/`: Contains the Application and Features following the TMA architecture. + - `Sources/`: Contains code and utilities used only in the module, except for modules that are shared across multiple features. + - `Tests/`: Contains unit tests for the module. + - `Interfaces/`: Contains protocols and interfaces that define the contract for communication between modules. + - `Resources/`: Contains assets, storyboards, and other resources used by the module. + - `Testing/`: Contains test utilities and mocks used for unit testing to use between modules. +- `Tuist/`: Contains the Tuist configuration files for project generation. +- `fastlane/`: Contains Fastlane configuration files for automating build and release processes. +- `scripts/`: Contains custom scripts for setup, build, and other tasks. + +Using the TMA architecture, the project is organized into modules that represent different features of the app. Each module is self-contained and can be developed and tested independently, promoting separation of concerns and modularity. It's common to only have public code and concrete, but this bring a problem with tests, because we have to create duplicates spies and mocks for each module, so we have a shared Testing folder to avoid this problem. Below is a diagram illustrating the project structure and dependencies between modules: + +TMA Architecture Diagram + +The use of Tuist allows for easy project generation and management, while Fastlane automates the build and release processes, ensuring a smooth development workflow. SwiftLint helps maintain code quality and consistency across the codebase. + +The modules consist of: +- **App:** The main application module that contains the app delegate, scene delegate, and the main coordinator responsible for navigation. +- **Home:** The feature module responsible for displaying the list of cryptocurrency exchanges. +- **Detail:** The feature module responsible for displaying the details of a specific exchange. +- **DependencyInjection:** A shared module that contains the dependency injection container and related configurations. This module is used to manage dependencies across the app and features, ensuring a clean separation of concerns and easier testing. +- **DesisgnSystem:** A shared module that contains reusable UI components, styles, and assets. This module promotes consistency across the app and allows for easy maintenance of the design system. +- **Navigation:** A shared module that contains navigation-related components and utilities, such as coordinators and navigation controllers. This module helps centralize navigation logic and promotes a clean separation of concerns. + +## Preview +
+ 📸 Screenshots +The app consists of two main screens: the Exchange List and the Exchange Detail. Below are screenshots of each screen: + +### Exchange List + + + + + + + + + + + + + + + +
Exchange List ScreenExchange List ScreenExchange List Screen
LoadingPre loadedLoaded
Dark
+ + + + + + + + + + + + + + + +
Exchange List ScreenExchange List ScreenExchange List Screen
LoadingPre loadedLoaded
Light
+ +### Exchange Detail + + + + + + + + + +
Exchange Detail ScreenExchange Detail Screen
Detail DarkDetail Light
+ +### Error + + + + + + + + + +
Exchange List Error ScreenExchange List Error Screen
Error DarkError Light
+
+ +# System Design +## Functional requirements +- **User can:** + - view a list of cryptocurrency exchanges. + - view details of a specific exchange. + - visit the exchange's website and twitter when have url + - view the app in light and dark mode + - view a loading indicator while data is being fetched + - view an error message if data fetching fails + - retry fetching data if an error occurs + +## Non-functional requirements +- **The app:** + - should have a clean and user-friendly UI. + - should be responsive and work on different screen sizes. + - should be performant and load data quickly. + - should consider scalability for future features and enhancements. + - should follow best practices for iOS development. + - should support iOS 15 and later. + - should be built using Swift 6 and UIKit. + - should follow the MVVM-C architecture for better separation of concerns and testability. + +## Data Entities Definitions +- **PreLoadedExchanges:** A list of exchanges with basic information (id, name) that is preloaded in the app for quick access. +- **Exchange:** Represents a cryptocurrency exchange with basic details such as id, name, spot volume in USD, date launched, and logo. +- **ExchangeDetail:** Represents detailed information about a specific exchange, including description, fees, weekly visits, and associated assets. +- **ExchangeURLs:** Contains various URLs related to the exchange, such as website, social media links, and fee information. +- **Assets:** Represents assets traded on the exchange, including id, name, symbol, and price in USD. +- **Status:** Represents the status of the API response, including timestamp, error code, error message, elapsed time, and credit count. + +## Entity Relationship Diagram + +```mermaid +erDiagram + ExchangeSummary { + Int id + String name + } + + Exchange { + Int id + String name + Double spot_volume_usd + Date date_launched + String logo + } + + ExchangeDetail ||--|| ExchangeURLs : "contains" + ExchangeDetail ||--o{ Assets : "includes" + + ExchangeDetail { + Int id + String name + String description + String logo + Double spot_volume_usd + Double marker_fee + Double taker_fee + Date date_launched + Int weekly_visits + } + + ExchangeURLs { + String_array website + String_array twitter + String_array blog + String_array chats + String_array fee + } + + Assets { + Int id + String name + String symbol + Double price_usd + } + + Status { + Date timestamp + Int error_Code + String error_Message + Int elapsed + Int credit_Count + } +``` + +## API Endpoints + +The application integrates with CoinMarketCap API using the following service layer endpoints: + +### 1. List Exchanges (Mapping) +`GET /v1/exchange/map` +- **Purpose:** Retrieves a lightweight list of list of exchanges. +- **Usage:** Used during the Bootstrap/Pre-loading phase to map exchange names to IDs and populate selection components. +- **Key Response:** PreLoadedExchanges entity. + +### 2. Exchange Metadata +`GET /v1/exchange/info?id={ids}` + +- **Purpose:** Fetches comprehensive metadata for one or more exchanges. +- **Usage:** Powers the Exchange Detail view, providing logos, descriptions, and official website URLs. +- **Parameter:** Supports a comma-separated list of IDs (e.g., id=1,2,3). +- **Key Response:** ExchangeDetail entity. + +### 3. Exchange Assets (Holdings) +`GET /v1/exchange/assets?id={id}` + +- **Purpose**: Returns the asset holdings (Proof of Reserves) for a specific exchange. +- **Usage:** Displays the Token Composition within the exchange details, including wallet addresses and balances across different blockchains. +- **Key Response:** List of Assets entities. + +## Error Handling & Status Codes +The system maps API responses to specific application states. Below are the handled HTTP status codes: + +| Status Code | Label | Description | +| :--- | :--- | :--- | +| **401** | Unauthorized | API Key is missing or invalid. | +| **403** | Forbidden | The IP is blacklisted or the API Key lacks required permissions. | +| **429** | Too Many Requests | Rate limit exceeded. The system implements a back-off strategy. | +| **500** | Internal Server Error | CoinMarketCap server-side issue. | + +- **Key Response:** Status entity. + +## High-Level Design (MVVM-C) +The application architecture is based on the MVVM-C (Model-View-ViewModel + Coordinator) pattern. This approach ensures a clear separation of concerns, improves testability, and centralizes navigation logic. + +### Architectural Components +- **Coordinator:** The "brain" of navigation. It removes the responsibility of flow control from View Controllers, making them independent and reusable. It handles the instantiation of ViewControllers and ViewModels. +- **ViewModel:** Acts as a mediator between the Model and the View. It holds the business logic, formats data for display, and is completely unaware of the UI framework (UIKit/SwiftUI), which makes it ideal for Unit Testing. +- **View (ViewController):** Responsible only for layout and capturing user interactions. It binds to the ViewModel to receive updates. +- **Service / API:** Handles all network requests and responses, abstracting the details of API communication. + +Model: Represents the data structures and the business logic layer (Repositories and Services). + +```mermaid +graph LR + %% Estilos + classDef coordinator fill:#f9f,stroke:#333,stroke-width:2px; + classDef view fill:#bbf,stroke:#333,stroke-width:2px; + classDef viewModel fill:#dfd,stroke:#333,stroke-width:2px; + classDef data fill:#ffd,stroke:#333,stroke-width:2px; + classDef di fill:#fff4dd,stroke:#d4a017,stroke-width:2px,stroke-dasharray: 5 5; + + %% Componentes + CO[Coordinator]:::coordinator + VC[ViewController]:::view + VM[ViewModel]:::viewModel + API[Network / API]:::data + DI[Dependency Injection]:::di + + %% Fluxo de Injeção + DI -.->|Provides Dependencies| CO + + %% Fluxo Horizontal + CO -->|Instantiates| VC + CO -->|Injects| VM + VC <-->|Data Binding| VM + VM -->|Requests Data| API + API -.->|JSON Response| VM +``` \ No newline at end of file diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved new file mode 100644 index 0000000..3cbbe40 --- /dev/null +++ b/Tuist/Package.resolved @@ -0,0 +1,59 @@ +{ + "pins" : [ + { + "identity" : "networking-package", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vitor-rc1/networking-package.git", + "state" : { + "revision" : "b5498ee2ab408592d4db9a9c95faec3a08de9ea2", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "2a2a938798236b8fa0bc57c453ee9de9f9ec3ab0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", + "state" : { + "revision" : "bf8d8c27f0f0c6d5e77bff0db76ab68f2050d15d", + "version" : "1.18.9" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swinject", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Swinject/Swinject.git", + "state" : { + "revision" : "b685b549fe4d8ae265fc7a2f27d0789720425d69", + "version" : "2.10.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "34e463e98ab8541c604af706c99bed7160f5ec70", + "version" : "1.8.1" + } + } + ], + "version" : 2 +} diff --git a/Tuist/Package.swift b/Tuist/Package.swift new file mode 100644 index 0000000..9cc58a7 --- /dev/null +++ b/Tuist/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +import PackageDescription + +#if TUIST + import ProjectDescription + + let packageSettings = PackageSettings( + productTypes: [ + "Swinject": .framework, + "NetworkingInterfaces": .framework, + "Networking": .framework + ] + ) +#endif + +let package = Package( + name: "ExchangesApp", + dependencies: [ + .package(url: "https://github.com/Swinject/Swinject.git", from: "2.9.1"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.18.9"), + .package(url: "https://github.com/vitor-rc1/networking-package.git", from: "1.1.0") + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Project+Template.swift b/Tuist/ProjectDescriptionHelpers/Project+Template.swift new file mode 100644 index 0000000..5cf2d28 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Project+Template.swift @@ -0,0 +1,127 @@ +import ProjectDescription + +public enum ModuleTargets: Equatable { + case test, testing, interfaces, app, source +} + +let cmApiBaseURL = Environment.cmApiBaseUrl.getString(default: "") +let cmApiKey = Environment.cmApiKey.getString(default: "") + +public let envs: [String: EnvironmentVariable] = [ + "CM_API_BASE_URL": EnvironmentVariable(stringLiteral: cmApiBaseURL), + "CM_API_KEY": EnvironmentVariable(stringLiteral: cmApiKey) +] + +public let iOSDeploymentTarget: DeploymentTargets = .iOS("15.0") +public let commonSettings: SettingsDictionary = [ + "SWIFT_VERSION": "6.0" +] + +public let swiftLintScript: TargetScript = .post(path: "../../scripts/swiftlint.sh", + arguments: "", + name: "Swiftlint") + +public extension Project { + static func templateModule(named moduleName: String, + targets: [ModuleTargets] = [.source, .interfaces, .test], + dependencies: [TargetDependency] = [], + testDependencies: [TargetDependency] = [], + interfaceDependecies: [TargetDependency] = [], + shouldSetEnvVars: Bool = false) -> Project { + var selectedTargets: [Target] = [] + + if targets.contains(.interfaces) { + selectedTargets.append( + .target(name: "\(moduleName)Interfaces", + destinations: .iOS, + product: .framework, + bundleId: "com.vrc.\(moduleName.lowercased()).interfaces", + deploymentTargets: iOSDeploymentTarget, + infoPlist: .default, + buildableFolders: ["Interfaces"], + scripts: [ + swiftLintScript + ], + dependencies: interfaceDependecies) + ) + } + + var moduleTargets: [TargetDependency] = dependencies + + if targets.contains(.interfaces) { + moduleTargets.append(.target(name: "\(moduleName)Interfaces")) + } + + if targets.contains(.source) { + selectedTargets.append( + .target(name: moduleName, + destinations: .iOS, + product: .framework, + bundleId: "com.vrc.\(moduleName.lowercased())", + deploymentTargets: iOSDeploymentTarget, + infoPlist: .default, + buildableFolders: ["Sources"], + scripts: [ + swiftLintScript + ], + dependencies: moduleTargets) + ) + } + + if targets.contains(.test) { + var testDeps: [TargetDependency] = testDependencies + testDeps.append(.target(name: moduleName)) + + if targets.contains(.testing) { + testDeps.append(.target(name: "\(moduleName)Testing")) + } + + selectedTargets.append( + .target(name: "\(moduleName)Tests", + destinations: .iOS, + product: .unitTests, + bundleId: "com.vrc.\(moduleName.lowercased()).tests", + deploymentTargets: iOSDeploymentTarget, + infoPlist: .default, + buildableFolders: ["Tests"], + dependencies: testDeps) + ) + } + + if targets.contains(.testing) && targets.contains(.interfaces) { + selectedTargets.append( + .target(name: "\(moduleName)Testing", + destinations: .iOS, + product: .framework, + bundleId: "com.vrc.\(moduleName.lowercased()).testing", + deploymentTargets: iOSDeploymentTarget, + infoPlist: .default, + buildableFolders: ["Testing"] , + dependencies: [ + .target(name: "\(moduleName)Interfaces") + ]) + ) + } + + return Project( + name: moduleName, + options: .options(automaticSchemesOptions: .disabled), + settings: .settings( + base: commonSettings + ), + targets: selectedTargets, + schemes: [ + .scheme( + name: moduleName, + shared: true, + buildAction: .buildAction(targets: ["\(moduleName)"]), + testAction: targets.contains(.test) ? .targets(["\(moduleName)Tests"]) : nil, + runAction: .runAction( + configuration: .debug, + arguments: shouldSetEnvVars ? .arguments(environmentVariables: envs) : nil + ) + ) + ] + ) + } +} diff --git a/Workspace.swift b/Workspace.swift new file mode 100644 index 0000000..cd635ed --- /dev/null +++ b/Workspace.swift @@ -0,0 +1,15 @@ +import Foundation +import ProjectDescription + +let workspace = Workspace( + name: "ExchangesApp", + projects: [ + "./Projects/App", + "./Projects/DependencyInjection", + "./Projects/DesignSystem", + "./Projects/Home", + "./Projects/Detail" + ], + schemes: [], + generationOptions: .options(autogeneratedWorkspaceSchemes: .disabled) +) diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..4282947 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,6 @@ +# app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app +# apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username + + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..e936b48 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,51 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +default_platform(:ios) + +platform :ios do + desc "Run Unit tests" + lane :tests do |options| + target = options[:target] || "App" + + run_tests(workspace: "ExchangesApp.xcworkspace", + device: "iPhone 17 (26.1)", + scheme: target, + code_coverage: true, + derived_data_path: "reports-#{target}/derived_data", + output_directory: "reports-#{target}/test_output",) + end + + lane :tests_coverage do |options| + target = options[:target] || "App" + slather( + proj: "Projects/#{target}/#{target}.xcodeproj", + scheme: target, + input_format: "profdata", + build_directory: "reports-#{target}/derived_data", + output_directory: "reports-#{target}/test_output", + source_directory: "Projects", + ignore: [ + "**/Derived/**", + "**/derived_data/**", + "**/Interfaces/**", + "**/Tests/**", + "**/Testing/**", + "Tuist/**", + ], + cobertura_xml: true + ) + end +end diff --git a/readme-assets/dark/error.png b/readme-assets/dark/error.png new file mode 100644 index 0000000..febfedf Binary files /dev/null and b/readme-assets/dark/error.png differ diff --git a/readme-assets/dark/exchange-detail.png b/readme-assets/dark/exchange-detail.png new file mode 100644 index 0000000..6947990 Binary files /dev/null and b/readme-assets/dark/exchange-detail.png differ diff --git a/readme-assets/dark/exchange-list-loaded.png b/readme-assets/dark/exchange-list-loaded.png new file mode 100644 index 0000000..0437afe Binary files /dev/null and b/readme-assets/dark/exchange-list-loaded.png differ diff --git a/readme-assets/dark/exchange-list-loading.png b/readme-assets/dark/exchange-list-loading.png new file mode 100644 index 0000000..fde202b Binary files /dev/null and b/readme-assets/dark/exchange-list-loading.png differ diff --git a/readme-assets/dark/exchange-list-pre-loaded.png b/readme-assets/dark/exchange-list-pre-loaded.png new file mode 100644 index 0000000..6598c7c Binary files /dev/null and b/readme-assets/dark/exchange-list-pre-loaded.png differ diff --git a/readme-assets/graph-dependency.png b/readme-assets/graph-dependency.png new file mode 100644 index 0000000..f130151 Binary files /dev/null and b/readme-assets/graph-dependency.png differ diff --git a/readme-assets/light/error.png b/readme-assets/light/error.png new file mode 100644 index 0000000..ea105fc Binary files /dev/null and b/readme-assets/light/error.png differ diff --git a/readme-assets/light/exchange-detail.png b/readme-assets/light/exchange-detail.png new file mode 100644 index 0000000..15dc916 Binary files /dev/null and b/readme-assets/light/exchange-detail.png differ diff --git a/readme-assets/light/exchange-list-loaded.png b/readme-assets/light/exchange-list-loaded.png new file mode 100644 index 0000000..9b01412 Binary files /dev/null and b/readme-assets/light/exchange-list-loaded.png differ diff --git a/readme-assets/light/exchange-list-loading.png b/readme-assets/light/exchange-list-loading.png new file mode 100644 index 0000000..cd171ae Binary files /dev/null and b/readme-assets/light/exchange-list-loading.png differ diff --git a/readme-assets/light/exchange-list-pre-loaded.png b/readme-assets/light/exchange-list-pre-loaded.png new file mode 100644 index 0000000..2a938eb Binary files /dev/null and b/readme-assets/light/exchange-list-pre-loaded.png differ diff --git a/scripts/ci-setup.sh b/scripts/ci-setup.sh new file mode 100644 index 0000000..ddde264 --- /dev/null +++ b/scripts/ci-setup.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +echo "CI setup Started" + +echo "Installing Brew dependencies..." +brew bundle install +echo "✅ Brew dependencies installed." + +echo "Installing Bundles..." +bundle config set without local +bundle install +echo "✅ Bundles installed." + +echo "Selecting Xcode..." +xcodes select 26.2.0 +echo "✅ Xcode selected." + +echo "Installing Project dependecies..." +tuist install +echo "✅ Project dependencies installed." + +echo "✅ CI setup completed." \ No newline at end of file diff --git a/scripts/generate-project.sh b/scripts/generate-project.sh new file mode 100644 index 0000000..b47380e --- /dev/null +++ b/scripts/generate-project.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Verifica se o ficheiro .env existe +if [ -f .env ]; then + # Lê o ficheiro linha a linha e exporta as variáveis + export $(grep -v '^#' .env | xargs) + echo "✅ Variáveis de ambiente carregadas do .env" +else + echo "⚠️ Ficheiro .env não encontrado!" +fi + +# Executa os comandos do Tuist +tuist install +tuist generate \ No newline at end of file diff --git a/scripts/local-setup.sh b/scripts/local-setup.sh new file mode 100644 index 0000000..d920983 --- /dev/null +++ b/scripts/local-setup.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +echo "✅ Local setup started." + +if command -v brew >/dev/null 2>&1; then + echo "✅ Homebrew found." +else + echo "⚠️ Homebrew not found. Please install manually following:" + echo "https://brew.sh/" + exit 1 +fi + +if command -v gem >/dev/null 2>&1; then + echo "✅ RubyGems found." +else + echo "⚠️ RubyGems not found. Please install manually." + exit 1 +fi + +echo "Installing Brew dependencies..." +brew bundle install +echo "✅ Brew dependencies installed." + +echo "Installing Bundles..." +bundle config set with local +bundle install +echo "✅ Bundles installed." + +echo "✅ Local setup completed." \ No newline at end of file diff --git a/scripts/swiftlint.sh b/scripts/swiftlint.sh new file mode 100755 index 0000000..c147eb1 --- /dev/null +++ b/scripts/swiftlint.sh @@ -0,0 +1,14 @@ +CONFIG_PATH="$SRCROOT/../../.swiftlint.yml" + +if [[ "$(uname -m)" == arm64 ]] +then + export PATH="/opt/homebrew/bin:$PATH" +fi + +if command -v swiftlint >/dev/null 2>&1 +then + swiftlint --config "$CONFIG_PATH" + # swiftlint +else + echo "warning: `swiftlint` command not found - See https://github.com/realm/SwiftLint#installation for installation instructions." +fi \ No newline at end of file