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:
+
+
+
+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
+
+
+
+  |
+  |
+  |
+
+
+ | Loading |
+ Pre loaded |
+ Loaded |
+
+
+ | Dark |
+
+
+
+
+
+  |
+  |
+  |
+
+
+ | Loading |
+ Pre loaded |
+ Loaded |
+
+
+ | Light |
+
+
+
+### Exchange Detail
+
+
+  |
+  |
+
+
+ | Detail Dark |
+ Detail Light |
+
+
+
+### Error
+
+
+  |
+  |
+
+
+ | Error Dark |
+ Error 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