├── .gitignore ├── .swiftlint.yml ├── CleanArchitecture.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── WorkspaceSettings.xcsettings │ └── swiftpm │ └── Package.resolved ├── CleanArchitecture ├── .gitignore ├── Package.swift └── Sources │ └── CleanArchitecture │ ├── ActivityIndicator.swift │ ├── Bindable.swift │ ├── CancelBag.swift │ ├── ErrorTracker.swift │ ├── GenericSubscriber.swift │ ├── LoadingState.swift │ ├── PagingInfo.swift │ ├── ViewModel+FetchItem.swift │ ├── ViewModel+FetchList.swift │ ├── ViewModel+FetchPage.swift │ └── ViewModel.swift ├── CleanArchitectureExample.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ ├── WorkspaceSettings.xcsettings │ └── swiftpm │ └── Package.resolved ├── CleanArchitectureExample ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Base.lproj │ └── LaunchScreen.storyboard ├── CleanArchitecture-Bridging-Header.h ├── Config │ └── ColorCompatibility.swift ├── ContentView.swift ├── Data │ └── Gateways │ │ ├── AuthGateway.swift │ │ ├── GitEndpoint.swift │ │ ├── ProductGateway.swift │ │ └── RepoGateway.swift ├── Domain │ ├── Entities │ │ ├── AppError.swift │ │ ├── Product.swift │ │ └── Repo.swift │ └── UseCases │ │ ├── Auth │ │ └── LogIn.swift │ │ ├── Product │ │ └── GetProducts.swift │ │ ├── Repo │ │ └── GetRepoList.swift │ │ └── Validations │ │ ├── ValidationError.swift │ │ └── ValidationResult.swift ├── Info.plist ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Scene │ ├── Common │ │ ├── ActivityIndicatorView.swift │ │ ├── AlertMessage.swift │ │ ├── BaseViewController.swift │ │ ├── LoadingView.swift │ │ └── ViewDidLoadModifier.swift │ ├── Login │ │ ├── LoginView.swift │ │ └── LoginViewModel.swift │ ├── Main │ │ ├── MainViewController.swift │ │ ├── MainViewModel.swift │ │ ├── MenuCell.swift │ │ ├── MenuCell.xib │ │ └── Storyboards+Main.swift │ ├── Navigators │ │ ├── ShowLogin.swift │ │ ├── ShowProductDetail.swift │ │ ├── ShowProductList.swift │ │ ├── ShowRepoCollection.swift │ │ ├── ShowRepoDetail.swift │ │ └── ShowRepoList.swift │ ├── ProductDetail │ │ ├── ProductDetailView.swift │ │ └── ProductDetailViewModel.swift │ ├── Products │ │ ├── ProductItemViewModel.swift │ │ ├── ProductRow.swift │ │ ├── ProductsView.swift │ │ └── ProductsViewModel.swift │ ├── RepoCollection │ │ ├── RepoCollectionCell.swift │ │ ├── RepoCollectionCell.xib │ │ └── RepoCollectionViewController.swift │ ├── Repos │ │ ├── Repo.storyboard │ │ ├── RepoCell.swift │ │ ├── RepoCell.xib │ │ ├── RepoItemViewModel.swift │ │ ├── ReposViewController.swift │ │ ├── ReposViewModel.swift │ │ └── Storyboards+Repo.swift │ └── Storyboards │ │ ├── Main.storyboard │ │ └── Storyboards.swift ├── SceneDelegate.swift └── Support │ └── Extensions │ ├── Double+.swift │ ├── Driver.swift │ ├── Observable.swift │ ├── Publisher+.swift │ ├── UIImageView+SDWebImage.swift │ ├── UIViewController+.swift │ ├── UIViewController+Combine.swift │ └── UIViewController+Debug.swift ├── CleanArchitectureExampleTests ├── Data │ └── Gateways │ │ ├── MockAuthGateway.swift │ │ ├── MockProductGateway.swift │ │ └── MockRepoGateway.swift ├── Domain │ └── UseCases │ │ └── LogInTests.swift ├── Extensions │ └── XCTestCase+.swift ├── Info.plist ├── Scene │ ├── Main │ │ ├── MainViewControllerTests.swift │ │ └── MainViewModelTests.swift │ ├── Products │ │ └── ProductsViewModelTests.swift │ └── Repos │ │ └── ReposViewModelTests.swift └── TestError.swift ├── CleanArchitectureExampleUITests └── LoginUITests.swift ├── LICENSE ├── Package.swift ├── PagingTableView ├── .gitignore ├── Package.swift └── Sources │ └── PagingTableView │ ├── PagingCollectionView.swift │ ├── PagingTableView.swift │ ├── RefreshFooterAnimator.swift │ └── RefreshHeaderAnimator.swift ├── README.md ├── files ├── xcode_project_template.zip └── xcode_templates.zip ├── igen.config ├── images ├── bridging_header.png ├── copy_files.png ├── create_bridging_header.png ├── data.png ├── delete_files.png ├── dependency_direction.png ├── detail_overview.png ├── domain.png ├── drag_files_folders.png ├── example.jpg ├── high_level_overview.png ├── mvvm_pattern.png ├── new_project.png ├── presentation.png ├── remove_scene_configuration.png ├── result.png ├── skeleton.png ├── swiftlint_run_script.png ├── template_scene_name.png ├── templates.png └── xcode_templates.png ├── xcode_project_template.md └── xcode_templates.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | # Package.resolved 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | Pods/ 50 | 51 | # Carthage 52 | # 53 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 54 | # Carthage/Checkouts 55 | 56 | Carthage/Build 57 | 58 | # fastlane 59 | # 60 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 61 | # screenshots whenever they are needed. 62 | # For more information about the recommended setup visit: 63 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 64 | 65 | fastlane/report.xml 66 | fastlane/Preview.html 67 | fastlane/screenshots/**/*.png 68 | fastlane/test_output 69 | 70 | .DS_Store 71 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers to exclude from running 2 | - trailing_whitespace 3 | - trailing_newline 4 | - xctfail_message 5 | - identifier_name 6 | - function_body_length 7 | - type_body_length 8 | - file_length 9 | - type_name 10 | - multiple_closures_with_trailing_closure 11 | 12 | opt_in_rules: # some rules are only opt-in 13 | # - anyobject_protocol 14 | # - array_init 15 | - attributes 16 | - block_based_kvo 17 | - class_delegate_protocol 18 | - closing_brace 19 | - closure_end_indentation 20 | - closure_parameter_position 21 | - closure_spacing 22 | - colon 23 | - comma 24 | - compiler_protocol_init 25 | # - conditional_returns_on_newline 26 | - contains_over_first_not_nil 27 | - control_statement 28 | - convenience_type 29 | - custom_rules 30 | - cyclomatic_complexity 31 | - discarded_notification_center_observer 32 | - discouraged_direct_init 33 | # - discouraged_object_literal 34 | - discouraged_optional_boolean 35 | # - discouraged_optional_collection 36 | - dynamic_inline 37 | - empty_count 38 | - empty_enum_arguments 39 | - empty_parameters 40 | - empty_parentheses_with_trailing_closure 41 | - empty_string 42 | - empty_xctest_method 43 | # - explicit_acl 44 | # - explicit_enum_raw_value 45 | - explicit_init 46 | # - explicit_top_level_acl 47 | # - explicit_type_interface 48 | # - extension_access_modifier 49 | - fallthrough 50 | - fatal_error_message 51 | # - file_header 52 | # - file_length 53 | # - file_name 54 | - first_where 55 | - for_where 56 | - force_cast 57 | - force_try 58 | - force_unwrapping 59 | # - function_body_length 60 | # - function_default_parameter_at_end 61 | - function_parameter_count 62 | - generic_type_name 63 | # - identifier_name 64 | - implicit_getter 65 | # - implicit_return 66 | # - implicitly_unwrapped_optional 67 | - is_disjoint 68 | - joined_default_parameter 69 | - large_tuple 70 | - leading_whitespace 71 | - legacy_cggeometry_functions 72 | - legacy_constant 73 | - legacy_constructor 74 | - legacy_nsgeometry_functions 75 | # - let_var_whitespace 76 | - line_length 77 | - literal_expression_end_indentation 78 | - lower_acl_than_parent 79 | - mark 80 | # - missing_docs 81 | - modifier_order 82 | - multiline_arguments 83 | - multiline_function_chains 84 | - multiline_parameters 85 | # - multiple_closures_with_trailing_closure 86 | - nesting 87 | - nimble_operator 88 | # - no_extension_access_modifier 89 | - no_fallthrough_only 90 | # - no_grouping_extension 91 | - notification_center_detachment 92 | # - number_separator 93 | # - object_literal 94 | - opening_brace 95 | - operator_usage_whitespace 96 | - operator_whitespace 97 | - overridden_super_call 98 | - override_in_extension 99 | - pattern_matching_keywords 100 | - prefixed_toplevel_constant 101 | # - private_action 102 | # - private_outlet 103 | - private_over_fileprivate 104 | - private_unit_test 105 | - prohibited_super_call 106 | - protocol_property_accessors_order 107 | - quick_discouraged_call 108 | - quick_discouraged_focused_test 109 | - quick_discouraged_pending_test 110 | - redundant_discardable_let 111 | - redundant_nil_coalescing 112 | - redundant_optional_initialization 113 | - redundant_set_access_control 114 | - redundant_string_enum_value 115 | - redundant_type_annotation 116 | - redundant_void_return 117 | - required_enum_case 118 | - return_arrow_whitespace 119 | - shorthand_operator 120 | - single_test_class 121 | - sorted_first_last 122 | # - sorted_imports 123 | - statement_position 124 | # - strict_fileprivate 125 | - superfluous_disable_command 126 | - switch_case_alignment 127 | - switch_case_on_newline 128 | - syntactic_sugar 129 | - todo 130 | # - trailing_closure 131 | - trailing_comma 132 | - trailing_newline 133 | - trailing_semicolon 134 | # - trailing_whitespace 135 | # - type_body_length 136 | # - type_name 137 | - unavailable_function 138 | - unneeded_break_in_switch 139 | # - unneeded_parentheses_in_closure_argument 140 | - untyped_error_in_catch 141 | - unused_closure_parameter 142 | - unused_enumerated 143 | - unused_optional_binding 144 | - valid_ibinspectable 145 | - vertical_parameter_alignment 146 | - vertical_parameter_alignment_on_call 147 | - vertical_whitespace 148 | - void_return 149 | - weak_delegate 150 | # - xctfail_message 151 | - yoda_condition 152 | # Find all the available rules by running: 153 | # swiftlint rules 154 | 155 | included: # paths to include during linting. `--path` is ignored if present. 156 | - CleanArchitecture 157 | - CleanArchitectureTests 158 | 159 | excluded: # paths to ignore during linting. Takes precedence over `included`. 160 | - Carthage 161 | - Pods 162 | 163 | line_length: 164 | warning: 120 165 | ignores_function_declarations: false 166 | ignores_comments: true 167 | ignores_urls: true 168 | 169 | function_parameter_count: 170 | warning: 6 171 | error: 8 172 | 173 | cyclomatic_complexity: 174 | warning: 15 175 | error: 25 176 | 177 | reporter: "xcode" # reporter type (xcode, jsm 178 | 179 | custom_rules: 180 | final_class: 181 | name: "Final Class" 182 | regex: "(^[ ]*class [A-Z]+)" 183 | message: "Class should be marked as final." 184 | severity: warning 185 | -------------------------------------------------------------------------------- /CleanArchitecture.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 9 | 10 | 13 | 16 | 18 | 19 | 21 | 22 | 24 | 25 | 27 | 28 | 30 | 31 | 33 | 34 | 36 | 37 | 39 | 40 | 42 | 43 | 45 | 46 | 48 | 49 | 50 | 51 | 52 | 54 | 55 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /CleanArchitecture.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CleanArchitecture.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CleanArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "combinecocoa", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/CombineCommunity/CombineCocoa.git", 7 | "state" : { 8 | "revision" : "7300c75ff9e072aa7fd0fccefcc88f74aae9bf56", 9 | "version" : "0.4.1" 10 | } 11 | }, 12 | { 13 | "identity" : "combineext", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/CombineCommunity/CombineExt.git", 16 | "state" : { 17 | "revision" : "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", 18 | "version" : "1.8.1" 19 | } 20 | }, 21 | { 22 | "identity" : "factory", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/hmlongco/Factory", 25 | "state" : { 26 | "revision" : "f350e0d71ba241b392f70519a67e769d5e3858d4", 27 | "version" : "2.4.1" 28 | } 29 | }, 30 | { 31 | "identity" : "mbprogresshud", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/jdg/MBProgressHUD", 34 | "state" : { 35 | "revision" : "bca42b801100b2b3a4eda0ba8dd33d858c780b0d", 36 | "version" : "1.2.0" 37 | } 38 | }, 39 | { 40 | "identity" : "pull-to-refresh", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/eggswift/pull-to-refresh", 43 | "state" : { 44 | "revision" : "a858aaeb44abc6428af916e8496429d244fac053", 45 | "version" : "2.9.3" 46 | } 47 | }, 48 | { 49 | "identity" : "reusable", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/AliSoftware/Reusable.git", 52 | "state" : { 53 | "revision" : "18674709421360e210c2ecd4e8e08b217d4ea61d", 54 | "version" : "4.1.2" 55 | } 56 | }, 57 | { 58 | "identity" : "rxswift", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/ReactiveX/RxSwift.git", 61 | "state" : { 62 | "revision" : "c7c7d2cf50a3211fe2843f76869c698e4e417930", 63 | "version" : "6.8.0" 64 | } 65 | }, 66 | { 67 | "identity" : "sdwebimage", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/SDWebImage/SDWebImage", 70 | "state" : { 71 | "revision" : "10d06f6a33bafae8c164fbfd1f03391f6d4692b3", 72 | "version" : "5.20.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swiftui-introspect", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/siteline/SwiftUI-Introspect.git", 79 | "state" : { 80 | "revision" : "121c146fe591b1320238d054ae35c81ffa45f45a", 81 | "version" : "0.12.0" 82 | } 83 | }, 84 | { 85 | "identity" : "swiftuirefresh", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/timbersoftware/SwiftUIRefresh.git", 88 | "state" : { 89 | "revision" : "fa8fac7b5eb5c729983a8bef65f094b5e0d12014", 90 | "version" : "0.0.3" 91 | } 92 | }, 93 | { 94 | "identity" : "tech-standard-ios-api", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/sun-asterisk/tech-standard-ios-api", 97 | "state" : { 98 | "revision" : "96d2a4555f94786e75685a8f8aea13b061af9f14", 99 | "version" : "0.19.0" 100 | } 101 | }, 102 | { 103 | "identity" : "then", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/devxoul/Then", 106 | "state" : { 107 | "revision" : "e421a7b3440a271834337694e6050133a3958bc7", 108 | "version" : "2.7.0" 109 | } 110 | } 111 | ], 112 | "version" : 2 113 | } 114 | -------------------------------------------------------------------------------- /CleanArchitecture/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /CleanArchitecture/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CleanArchitecture", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, making them visible to other packages. 13 | .library( 14 | name: "CleanArchitecture", 15 | targets: ["CleanArchitecture"]), 16 | ], 17 | targets: [ 18 | // Targets are the basic building blocks of a package, defining a module or a test suite. 19 | // Targets can depend on other targets in this package and products from dependencies. 20 | .target( 21 | name: "CleanArchitecture"), 22 | 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | // typealias for ActivityIndicator, a CurrentValueSubject that tracks activity status (true/false) 4 | public typealias ActivityIndicator = CurrentValueSubject 5 | 6 | extension Publisher where Failure: Error { 7 | /// Track activity status of the publisher using an ActivityIndicator. 8 | /// 9 | /// - Parameter activityIndicator: An instance of ActivityIndicator used to track activity status of the publisher. 10 | /// - Returns: A publisher that emits the same output and failure types as the original publisher. 11 | public func trackActivity(_ activityIndicator: ActivityIndicator) -> AnyPublisher { 12 | return handleEvents( 13 | receiveSubscription: { _ in 14 | activityIndicator.send(true) // Send true to indicate activity started 15 | }, 16 | receiveCompletion: { _ in 17 | activityIndicator.send(false) // Send false to indicate activity ended 18 | }, 19 | receiveCancel: { 20 | activityIndicator.send(false) // Send false to indicate activity ended due to cancellation 21 | } 22 | ) 23 | .eraseToAnyPublisher() // Erase the type of the publisher to AnyPublisher to hide implementation details 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/Bindable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | /// A protocol that defines a bindable ViewController. 4 | /// ViewControllers conforming to this protocol should bind to a ViewModel of a specified type. 5 | public protocol Bindable: AnyObject { 6 | /// The type of ViewModel associated with the ViewController. 7 | associatedtype ViewModel 8 | 9 | /// The ViewModel instance that will be bound to the ViewController. 10 | var viewModel: ViewModel! { get set } 11 | 12 | /// A method to bind the ViewModel to the ViewController. 13 | /// This method should be implemented by conforming ViewControllers to establish bindings between the ViewModel and the ViewController. 14 | func bindViewModel() 15 | } 16 | 17 | extension Bindable where Self: UIViewController { 18 | /// Binds the provided ViewModel to the ViewController. 19 | /// 20 | /// - Parameter viewModel: The ViewModel instance to bind to the ViewController. 21 | public func bindViewModel(to viewModel: Self.ViewModel) { 22 | self.viewModel = viewModel 23 | loadViewIfNeeded() // Ensure the view is loaded before binding 24 | bindViewModel() // Call the method to bind the ViewModel to the ViewController 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/CancelBag.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | /// A container that holds a collection of cancellable subscriptions. 4 | /// Useful for managing the lifecycle of multiple Combine subscriptions. 5 | open class CancelBag { 6 | /// The set of cancellable subscriptions. 7 | public fileprivate(set) var subscriptions = Set() 8 | 9 | /// Initializes a new instance of `CancelBag`. 10 | public init() {} 11 | 12 | /// Cancels all the subscriptions stored in the `CancelBag`. 13 | public func cancel() { 14 | subscriptions.forEach { $0.cancel() } 15 | subscriptions.removeAll(keepingCapacity: false) 16 | } 17 | } 18 | 19 | extension CancelBag: ObservableObject { } 20 | 21 | extension AnyCancellable { 22 | /// Stores the cancellable subscription in the specified `CancelBag`. 23 | /// 24 | /// - Parameter cancelBag: The `CancelBag` in which to store the subscription. 25 | public func store(in cancelBag: CancelBag) { 26 | cancelBag.subscriptions.insert(self) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/ErrorTracker.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | // Typealias for ErrorTracker, a PassthroughSubject used to track errors. 4 | public typealias ErrorTracker = PassthroughSubject 5 | 6 | extension Publisher where Failure: Error { 7 | 8 | /// Track errors emitted by the publisher using an ErrorTracker. 9 | /// 10 | /// - Parameter errorTracker: An instance of ErrorTracker used to track errors emitted by the publisher. 11 | /// - Returns: A publisher that emits the same output and failure types as the original publisher. 12 | public func trackError(_ errorTracker: ErrorTracker) -> AnyPublisher { 13 | handleEvents(receiveCompletion: { completion in 14 | // When completion is received, if it's a failure, send the error to the errorTracker 15 | if case let .failure(error) = completion { 16 | errorTracker.send(error) 17 | } 18 | }) 19 | // Erase the type of the publisher to AnyPublisher to hide implementation details 20 | .eraseToAnyPublisher() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/GenericSubscriber.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenericSubscriber.swift 3 | // CleanArchitecture 4 | // 5 | import Combine 6 | 7 | /// A generic subscriber that holds a weak reference to a target to prevent retain cycles. 8 | /// It subscribes to a publisher and invokes a closure with the emitted value. 9 | public struct GenericSubscriber: Subscriber { 10 | public var combineIdentifier = CombineIdentifier() 11 | private let onReceiveValue: (Value) -> Void 12 | 13 | /// Initializes a `GenericSubscriber` with a weak reference to the target. 14 | /// 15 | /// - Parameters: 16 | /// - target: The target object to receive values. 17 | /// - subscribing: A closure to be invoked with the target and received value. 18 | public init(_ target: Target, subscribing: @escaping (Target, Value) -> Void) { 19 | weak var weakTarget = target 20 | self.onReceiveValue = { value in 21 | guard let target = weakTarget else { return } 22 | subscribing(target, value) 23 | } 24 | } 25 | 26 | public func receive(subscription: Subscription) { 27 | subscription.request(.unlimited) // Requests unlimited values from the publisher 28 | } 29 | 30 | public func receive(_ input: Value) -> Subscribers.Demand { 31 | onReceiveValue(input) 32 | return .none 33 | } 34 | 35 | public func receive(completion: Subscribers.Completion) { 36 | // Optionally handle completion if needed 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/LoadingState.swift: -------------------------------------------------------------------------------- 1 | /// An enumeration representing the different loading states of a screen. 2 | /// This enum helps in distinguishing between initial loading, reloading, and loading more states with associated input data. 3 | public enum LoadingState { 4 | /// Represents the initial loading state with associated input data. 5 | case initial(Input) 6 | 7 | /// Represents the reloading state with associated input data. 8 | case reload(Input) 9 | 10 | /// Represents the loading more state (for pagination) with associated input data. 11 | case loadMore(Input) 12 | } 13 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/PagingInfo.swift: -------------------------------------------------------------------------------- 1 | /// A struct representing paginated information for a collection of items. 2 | /// This struct encapsulates details about the current page, the items, and pagination metadata. 3 | public struct PagingInfo { 4 | /// The current page number. 5 | public var page: Int 6 | 7 | /// The items on the current page. 8 | public var items: [T] 9 | 10 | /// A flag indicating whether there are more pages available. 11 | public var hasMorePages: Bool 12 | 13 | /// The total number of items available across all pages. 14 | public var totalItems: Int 15 | 16 | /// The number of items per page. 17 | public var itemsPerPage: Int 18 | 19 | /// The total number of pages available. 20 | public var totalPages: Int 21 | 22 | /// Initializes a new instance of `PagingInfo` with full pagination details. 23 | /// 24 | /// - Parameters: 25 | /// - page: The current page number. 26 | /// - items: The items on the current page. 27 | /// - hasMorePages: A flag indicating whether there are more pages available. 28 | /// - totalItems: The total number of items available across all pages. 29 | /// - itemsPerPage: The number of items per page. 30 | /// - totalPages: The total number of pages available. 31 | public init(page: Int, 32 | items: [T], 33 | hasMorePages: Bool, 34 | totalItems: Int, 35 | itemsPerPage: Int, 36 | totalPages: Int) { 37 | self.page = page 38 | self.items = items 39 | self.hasMorePages = hasMorePages 40 | self.totalItems = totalItems 41 | self.itemsPerPage = itemsPerPage 42 | self.totalPages = totalPages 43 | } 44 | 45 | /// Initializes a new instance of `PagingInfo` with basic pagination details. 46 | /// 47 | /// - Parameters: 48 | /// - page: The current page number. 49 | /// - items: The items on the current page. 50 | /// - hasMorePages: A flag indicating whether there are more pages available. 51 | public init(page: Int, items: [T], hasMorePages: Bool) { 52 | self.init(page: page, 53 | items: items, 54 | hasMorePages: hasMorePages, 55 | totalItems: 0, 56 | itemsPerPage: 0, 57 | totalPages: 0) 58 | } 59 | 60 | /// Initializes a new instance of `PagingInfo` with the current page and items. 61 | /// 62 | /// - Parameters: 63 | /// - page: The current page number. 64 | /// - items: The items on the current page. 65 | public init(page: Int, items: [T]) { 66 | self.init(page: page, 67 | items: items, 68 | hasMorePages: true, 69 | totalItems: 0, 70 | itemsPerPage: 0, 71 | totalPages: 0) 72 | } 73 | 74 | /// Initializes a new instance of `PagingInfo` with default values. 75 | /// This initializer sets the current page to 1, an empty items array, and assumes more pages are available. 76 | public init() { 77 | self.init(page: 1, 78 | items: [], 79 | hasMorePages: true, 80 | totalItems: 0, 81 | itemsPerPage: 0, 82 | totalPages: 0) 83 | } 84 | } 85 | 86 | extension PagingInfo: Equatable where T: Equatable { 87 | /// Conformance to the Equatable protocol for PagingInfo when the items are Equatable. 88 | /// This allows two PagingInfo instances to be compared for equality based on their properties. 89 | } 90 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/ViewModel+FetchItem.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | /// A struct representing the configuration required to fetch a single item. 4 | /// This struct encapsulates the error tracker, load triggers, and item fetchers. 5 | public struct FetchItemConfig { 6 | /// A tracker for handling and tracking errors. 7 | let errorTracker: ErrorTracker 8 | 9 | /// A publisher that triggers the initial load of the item. 10 | let initialLoadTrigger: AnyPublisher 11 | 12 | /// A publisher that triggers the reload of the item. 13 | let reloadTrigger: AnyPublisher 14 | 15 | /// A closure that fetches the item based on the provided input. 16 | let fetchItem: (TriggerInput) -> AnyPublisher 17 | 18 | /// A closure that reloads the item based on the provided input. 19 | let reloadItem: (TriggerInput) -> AnyPublisher 20 | 21 | /// Initializes a new instance of `FetchItemConfig`. 22 | /// 23 | /// - Parameters: 24 | /// - errorTracker: A tracker for handling and tracking errors. 25 | /// - initialLoadTrigger: A publisher that triggers the initial load of the item. 26 | /// - reloadTrigger: A publisher that triggers the reload of the item. 27 | /// - fetchItem: A closure that fetches the item based on the provided input. 28 | /// - reloadItem: A closure that reloads the item based on the provided input. 29 | public init(errorTracker: ErrorTracker, 30 | initialLoadTrigger: AnyPublisher, 31 | fetchItem: @escaping (TriggerInput) -> AnyPublisher, 32 | reloadTrigger: AnyPublisher, 33 | reloadItem: @escaping (TriggerInput) -> AnyPublisher) { 34 | self.errorTracker = errorTracker 35 | self.initialLoadTrigger = initialLoadTrigger 36 | self.reloadTrigger = reloadTrigger 37 | self.fetchItem = fetchItem 38 | self.reloadItem = reloadItem 39 | } 40 | } 41 | 42 | public extension FetchItemConfig { 43 | /// Initializes a new instance of `FetchItemConfig` with a default error tracker. 44 | /// This initializer uses the same closure for both fetching and reloading the item. 45 | /// 46 | /// - Parameters: 47 | /// - errorTracker: A tracker for handling and tracking errors. Defaults to a new instance of `ErrorTracker`. 48 | /// - initialLoadTrigger: A publisher that triggers the initial load of the item. 49 | /// - reloadTrigger: A publisher that triggers the reload of the item. 50 | /// - fetchItem: A closure that fetches the item based on the provided input. 51 | init(errorTracker: ErrorTracker = ErrorTracker(), 52 | initialLoadTrigger: AnyPublisher, 53 | reloadTrigger: AnyPublisher, 54 | fetchItem: @escaping (TriggerInput) -> AnyPublisher) { 55 | self.init(errorTracker: errorTracker, 56 | initialLoadTrigger: initialLoadTrigger, 57 | fetchItem: fetchItem, 58 | reloadTrigger: reloadTrigger, 59 | reloadItem: fetchItem) 60 | } 61 | } 62 | 63 | /// A struct representing the result of fetching a single item. 64 | /// This struct encapsulates the publishers for the item, error, loading, and reloading states. 65 | public struct FetchItemResult { 66 | /// A publisher that emits the item. 67 | public var item: AnyPublisher 68 | 69 | /// A publisher that emits errors that occur during the fetching process. 70 | public var error: AnyPublisher 71 | 72 | /// A publisher that emits the loading state. 73 | public var isLoading: AnyPublisher 74 | 75 | /// A publisher that emits the reloading state. 76 | public var isReloading: AnyPublisher 77 | 78 | /// A tuple containing all the publishers, useful for destructuring. 79 | public var destructured: ( // swiftlint:disable:this large_tuple 80 | AnyPublisher, 81 | AnyPublisher, 82 | AnyPublisher, 83 | AnyPublisher 84 | ) { 85 | (item, error, isLoading, isReloading) 86 | } 87 | 88 | /// Initializes a new instance of `FetchItemResult`. 89 | /// 90 | /// - Parameters: 91 | /// - item: A publisher that emits the item. 92 | /// - error: A publisher that emits errors that occur during the fetching process. 93 | /// - isLoading: A publisher that emits the loading state. 94 | /// - isReloading: A publisher that emits the reloading state. 95 | public init(item: AnyPublisher, 96 | error: AnyPublisher, 97 | isLoading: AnyPublisher, 98 | isReloading: AnyPublisher) { 99 | self.item = item 100 | self.error = error 101 | self.isLoading = isLoading 102 | self.isReloading = isReloading 103 | } 104 | } 105 | 106 | /// Extension for ViewModel to provide a method to fetch a single item. 107 | extension ViewModel { 108 | 109 | /// Fetches a single item based on the provided `FetchItemConfig`. 110 | /// 111 | /// - Parameter config: The configuration for fetching the item. 112 | /// - Returns: A `FetchItemResult` containing the publishers for the item, error, loading, and reloading states. 113 | public func fetchItem(config: FetchItemConfig) -> FetchItemResult { 114 | 115 | let loadingActivityIndicator = ActivityIndicator(false) 116 | let reloadingActivityIndicator = ActivityIndicator(false) 117 | 118 | let item = Publishers.Merge( 119 | config.initialLoadTrigger.map { LoadingState.initial($0) }, 120 | config.reloadTrigger.map { LoadingState.reload($0) } 121 | ) 122 | .filter { _ in 123 | !loadingActivityIndicator.value && !reloadingActivityIndicator.value 124 | } 125 | .map { triggerType -> AnyPublisher in 126 | switch triggerType { 127 | case .initial(let triggerInput): 128 | return config.fetchItem(triggerInput) 129 | .trackError(config.errorTracker) 130 | .trackActivity(loadingActivityIndicator) 131 | .catch { _ in Empty() } 132 | .eraseToAnyPublisher() 133 | case .reload(let triggerInput): 134 | return config.reloadItem(triggerInput) 135 | .trackError(config.errorTracker) 136 | .trackActivity(reloadingActivityIndicator) 137 | .catch { _ in Empty() } 138 | .eraseToAnyPublisher() 139 | default: 140 | return Empty().eraseToAnyPublisher() 141 | } 142 | } 143 | .switchToLatest() 144 | .eraseToAnyPublisher() 145 | 146 | let error = config.errorTracker.eraseToAnyPublisher() 147 | let isLoading = loadingActivityIndicator.eraseToAnyPublisher() 148 | let isReloading = reloadingActivityIndicator.eraseToAnyPublisher() 149 | 150 | return FetchItemResult( 151 | item: item, 152 | error: error, 153 | isLoading: isLoading, 154 | isReloading: isReloading 155 | ) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/ViewModel+FetchList.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | /// A struct representing the configuration required to fetch a list of items. 4 | /// This struct encapsulates the error tracker, load triggers, item fetchers, and item mapper. 5 | public struct ListFetchConfig { 6 | /// A tracker for handling and tracking errors. 7 | let errorTracker: ErrorTracker 8 | 9 | /// A publisher that triggers the initial load of items. 10 | let initialLoadTrigger: AnyPublisher 11 | 12 | /// A publisher that triggers the reload of items. 13 | let reloadTrigger: AnyPublisher 14 | 15 | /// A closure that fetches items based on the provided input. 16 | let fetchItems: (TriggerInput) -> AnyPublisher<[Item], Error> 17 | 18 | /// A closure that reloads items based on the provided input. 19 | let reloadItems: (TriggerInput) -> AnyPublisher<[Item], Error> 20 | 21 | /// A closure that maps an item to a mapped item. 22 | let itemMapper: (Item) -> MappedItem 23 | 24 | /// Initializes a new instance of `ListFetchConfig`. 25 | /// 26 | /// - Parameters: 27 | /// - errorTracker: A tracker for handling and tracking errors. 28 | /// - initialLoadTrigger: A publisher that triggers the initial load of items. 29 | /// - reloadTrigger: A publisher that triggers the reload of items. 30 | /// - fetchItems: A closure that fetches items based on the provided input. 31 | /// - reloadItems: A closure that reloads items based on the provided input. 32 | /// - itemMapper: A closure that maps an item to a mapped item. 33 | public init(errorTracker: ErrorTracker, 34 | initialLoadTrigger: AnyPublisher, 35 | reloadTrigger: AnyPublisher, 36 | fetchItems: @escaping (TriggerInput) -> AnyPublisher<[Item], Error>, 37 | reloadItems: @escaping (TriggerInput) -> AnyPublisher<[Item], Error>, 38 | itemMapper: @escaping (Item) -> MappedItem) { 39 | self.errorTracker = errorTracker 40 | self.initialLoadTrigger = initialLoadTrigger 41 | self.reloadTrigger = reloadTrigger 42 | self.fetchItems = fetchItems 43 | self.reloadItems = reloadItems 44 | self.itemMapper = itemMapper 45 | } 46 | } 47 | 48 | public extension ListFetchConfig { 49 | /// Initializes a new instance of `ListFetchConfig` with a default error tracker. 50 | /// This initializer uses the same closure for both fetching and reloading items. 51 | /// 52 | /// - Parameters: 53 | /// - errorTracker: A tracker for handling and tracking errors. Defaults to a new instance of `ErrorTracker`. 54 | /// - initialLoadTrigger: A publisher that triggers the initial load of items. 55 | /// - reloadTrigger: A publisher that triggers the reload of items. 56 | /// - fetchItems: A closure that fetches items based on the provided input. 57 | /// - itemMapper: A closure that maps an item to a mapped item. 58 | init(errorTracker: ErrorTracker = ErrorTracker(), 59 | initialLoadTrigger: AnyPublisher, 60 | reloadTrigger: AnyPublisher, 61 | fetchItems: @escaping (TriggerInput) -> AnyPublisher<[Item], Error>, 62 | itemMapper: @escaping (Item) -> MappedItem) { 63 | self.init(errorTracker: errorTracker, 64 | initialLoadTrigger: initialLoadTrigger, 65 | reloadTrigger: reloadTrigger, 66 | fetchItems: fetchItems, 67 | reloadItems: fetchItems, 68 | itemMapper: itemMapper) 69 | } 70 | } 71 | 72 | public extension ListFetchConfig where Item == MappedItem { 73 | /// Initializes a new instance of `ListFetchConfig` when `Item` and `MappedItem` are the same type. 74 | /// This initializer uses the same closure for both fetching and reloading items, and uses an identity mapper. 75 | /// 76 | /// - Parameters: 77 | /// - errorTracker: A tracker for handling and tracking errors. Defaults to a new instance of `ErrorTracker`. 78 | /// - initialLoadTrigger: A publisher that triggers the initial load of items. 79 | /// - reloadTrigger: A publisher that triggers the reload of items. 80 | /// - fetchItems: A closure that fetches items based on the provided input. 81 | init(errorTracker: ErrorTracker = ErrorTracker(), 82 | initialLoadTrigger: AnyPublisher, 83 | reloadTrigger: AnyPublisher, 84 | fetchItems: @escaping (TriggerInput) -> AnyPublisher<[Item], Error>) { 85 | self.init(errorTracker: errorTracker, 86 | initialLoadTrigger: initialLoadTrigger, 87 | reloadTrigger: reloadTrigger, 88 | fetchItems: fetchItems, 89 | reloadItems: fetchItems, 90 | itemMapper: { $0 }) // Identity mapper 91 | } 92 | } 93 | 94 | /// A struct representing the result of fetching a list of items. 95 | /// This struct encapsulates the publishers for the items, error, loading, and reloading states. 96 | public struct FetchResult { 97 | /// A publisher that emits the list of items. 98 | public var items: AnyPublisher<[Item], Never> 99 | 100 | /// A publisher that emits errors that occur during the fetching process. 101 | public var error: AnyPublisher 102 | 103 | /// A publisher that emits the loading state. 104 | public var isLoading: AnyPublisher 105 | 106 | /// A publisher that emits the reloading state. 107 | public var isReloading: AnyPublisher 108 | 109 | /// A tuple containing all the publishers, useful for destructuring. 110 | public var destructured: ( // swiftlint:disable:this large_tuple 111 | AnyPublisher<[Item], Never>, 112 | AnyPublisher, 113 | AnyPublisher, 114 | AnyPublisher 115 | ) { 116 | (items, error, isLoading, isReloading) 117 | } 118 | 119 | /// Initializes a new instance of `FetchResult`. 120 | /// 121 | /// - Parameters: 122 | /// - items: A publisher that emits the list of items. 123 | /// - error: A publisher that emits errors that occur during the fetching process. 124 | /// - isLoading: A publisher that emits the loading state. 125 | /// - isReloading: A publisher that emits the reloading state. 126 | public init(items: AnyPublisher<[Item], Never>, 127 | error: AnyPublisher, 128 | isLoading: AnyPublisher, 129 | isReloading: AnyPublisher) { 130 | self.items = items 131 | self.error = error 132 | self.isLoading = isLoading 133 | self.isReloading = isReloading 134 | } 135 | } 136 | 137 | /// Extension for ViewModel to provide a method to fetch a list of items. 138 | extension ViewModel { 139 | 140 | /// Fetches a list of items based on the provided `ListFetchConfig`. 141 | /// 142 | /// - Parameter input: The configuration for fetching the list of items. 143 | /// - Returns: A `FetchResult` containing the publishers for items, error, loading, and reloading states. 144 | public func fetchList(config: ListFetchConfig) 145 | -> FetchResult { 146 | 147 | let loadingActivityIndicator = ActivityIndicator(false) 148 | let reloadingActivityIndicator = ActivityIndicator(false) 149 | 150 | let items = Publishers.Merge( 151 | config.initialLoadTrigger.map { LoadingState.initial($0) }, 152 | config.reloadTrigger.map { LoadingState.reload($0) } 153 | ) 154 | .filter { _ in 155 | !loadingActivityIndicator.value && !reloadingActivityIndicator.value 156 | } 157 | .map { triggerType -> AnyPublisher<[Item], Never> in 158 | switch triggerType { 159 | case .initial(let triggerInput): 160 | return config.fetchItems(triggerInput) 161 | .trackError(config.errorTracker) 162 | .trackActivity(loadingActivityIndicator) 163 | .catch { _ in Empty() } 164 | .eraseToAnyPublisher() 165 | case .reload(let triggerInput): 166 | return config.reloadItems(triggerInput) 167 | .trackError(config.errorTracker) 168 | .trackActivity(reloadingActivityIndicator) 169 | .catch { _ in Empty() } 170 | .eraseToAnyPublisher() 171 | default: 172 | return Empty().eraseToAnyPublisher() 173 | } 174 | } 175 | .switchToLatest() 176 | .map { $0.map(config.itemMapper) } 177 | .eraseToAnyPublisher() 178 | 179 | let error = config.errorTracker.eraseToAnyPublisher() 180 | let isLoading = loadingActivityIndicator.eraseToAnyPublisher() 181 | let isReloading = reloadingActivityIndicator.eraseToAnyPublisher() 182 | 183 | return FetchResult( 184 | items: items, 185 | error: error, 186 | isLoading: isLoading, 187 | isReloading: isReloading 188 | ) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /CleanArchitecture/Sources/CleanArchitecture/ViewModel.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | /// A protocol representing a ViewModel in the MVVM architecture. 4 | /// ViewModels conforming to this protocol should transform input to output, and manage the cancellation of ongoing tasks. 5 | public protocol ViewModel: ObservableObject { 6 | /// The type of input that the ViewModel will receive. 7 | associatedtype Input 8 | /// The type of output that the ViewModel will produce. 9 | associatedtype Output = Never 10 | 11 | /// Transforms the input to the output. 12 | /// - Parameters: 13 | /// - input: The input to be transformed. 14 | /// - cancelBag: A container to manage the cancellation of ongoing tasks. 15 | /// - Returns: The transformed output. 16 | func transform(_ input: Input, cancelBag: CancelBag) -> Output 17 | } 18 | 19 | extension ViewModel where Output == Never { 20 | /// A default implementation of the transform function for ViewModels that do not produce any output. 21 | /// This is useful for cases where the ViewModel only handles input without producing any output. 22 | /// - Parameters: 23 | /// - input: The input to be processed. 24 | /// - cancelBag: A container to manage the cancellation of ongoing tasks. 25 | public func transform(_ input: Input, cancelBag: CancelBag) { 26 | // Default implementation does nothing since there is no output. 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /CleanArchitectureExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CleanArchitectureExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CleanArchitectureExample.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CleanArchitectureExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "c73773eb9b559b24f54ca5881762f3a5faac7d3cce998e1f17361cc23e10907e", 3 | "pins" : [ 4 | { 5 | "identity" : "combinecocoa", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/CombineCommunity/CombineCocoa.git", 8 | "state" : { 9 | "revision" : "7300c75ff9e072aa7fd0fccefcc88f74aae9bf56", 10 | "version" : "0.4.1" 11 | } 12 | }, 13 | { 14 | "identity" : "combineext", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/CombineCommunity/CombineExt.git", 17 | "state" : { 18 | "revision" : "d7b896fa9ca8b47fa7bcde6b43ef9b70bf8c1f56", 19 | "version" : "1.8.1" 20 | } 21 | }, 22 | { 23 | "identity" : "factory", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/hmlongco/Factory", 26 | "state" : { 27 | "revision" : "8ca11a7bd1ede031e8e6d7a912bb116e2e43961b", 28 | "version" : "2.3.1" 29 | } 30 | }, 31 | { 32 | "identity" : "mbprogresshud", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/jdg/MBProgressHUD", 35 | "state" : { 36 | "revision" : "bca42b801100b2b3a4eda0ba8dd33d858c780b0d", 37 | "version" : "1.2.0" 38 | } 39 | }, 40 | { 41 | "identity" : "pull-to-refresh", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/eggswift/pull-to-refresh", 44 | "state" : { 45 | "revision" : "a858aaeb44abc6428af916e8496429d244fac053", 46 | "version" : "2.9.3" 47 | } 48 | }, 49 | { 50 | "identity" : "reusable", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/AliSoftware/Reusable.git", 53 | "state" : { 54 | "revision" : "18674709421360e210c2ecd4e8e08b217d4ea61d", 55 | "version" : "4.1.2" 56 | } 57 | }, 58 | { 59 | "identity" : "sdwebimage", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/SDWebImage/SDWebImage", 62 | "state" : { 63 | "revision" : "59730af512c06fb569c119d737df4c1c499e001d", 64 | "version" : "5.18.10" 65 | } 66 | }, 67 | { 68 | "identity" : "swiftui-introspect", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/siteline/SwiftUI-Introspect.git", 71 | "state" : { 72 | "revision" : "121c146fe591b1320238d054ae35c81ffa45f45a", 73 | "version" : "0.12.0" 74 | } 75 | }, 76 | { 77 | "identity" : "swiftuirefresh", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/timbersoftware/SwiftUIRefresh.git", 80 | "state" : { 81 | "revision" : "fa8fac7b5eb5c729983a8bef65f094b5e0d12014", 82 | "version" : "0.0.3" 83 | } 84 | }, 85 | { 86 | "identity" : "tech-standard-ios-api", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/sun-asterisk/tech-standard-ios-api", 89 | "state" : { 90 | "revision" : "70356764432069d92d520b47ca04f7f20414d779", 91 | "version" : "0.14.0" 92 | } 93 | }, 94 | { 95 | "identity" : "then", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/devxoul/Then", 98 | "state" : { 99 | "revision" : "e421a7b3440a271834337694e6050133a3958bc7", 100 | "version" : "2.7.0" 101 | } 102 | } 103 | ], 104 | "version" : 3 105 | } 106 | -------------------------------------------------------------------------------- /CleanArchitectureExample/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | final class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | func application(_ application: UIApplication, 15 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | // Override point for customization after application launch. 17 | return true 18 | } 19 | 20 | // MARK: UISceneSession Lifecycle 21 | 22 | func application(_ application: UIApplication, 23 | configurationForConnecting connectingSceneSession: UISceneSession, 24 | options: UIScene.ConnectionOptions) -> UISceneConfiguration { 25 | // Called when a new scene session is being created. 26 | // Use this method to select a configuration to create the new scene with. 27 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 28 | } 29 | 30 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 31 | // Called when the user discards a scene session. 32 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 33 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 34 | } 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /CleanArchitectureExample/CleanArchitecture-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Config/ColorCompatibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorCompatibility.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/5/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | enum ColorCompatibility { 12 | static var newCard: UIColor { 13 | return ColorCompatibility.systemBlue 14 | } 15 | 16 | static var learningCard: UIColor { 17 | return ColorCompatibility.systemGreen 18 | } 19 | 20 | static var reviewingCard: UIColor { 21 | return ColorCompatibility.systemRed 22 | } 23 | 24 | static var label: UIColor { 25 | if #available(iOS 13, *) { 26 | return .label 27 | } 28 | return UIColor.black 29 | } 30 | 31 | static var systemBackground: UIColor { 32 | if #available(iOS 13, *) { 33 | return .systemBackground 34 | } 35 | return UIColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0) 36 | } 37 | 38 | static var secondaryLabel: UIColor { 39 | if #available(iOS 13, *) { 40 | return .secondaryLabel 41 | } 42 | return UIColor(red: 0.23529411764705882, green: 0.23529411764705882, blue: 0.2627450980392157, alpha: 0.6) 43 | } 44 | 45 | static var separator: UIColor { 46 | if #available(iOS 13, *) { 47 | return .separator 48 | } 49 | return UIColor(red: 0.23529411764705882, green: 0.23529411764705882, blue: 0.2627450980392157, alpha: 0.29) 50 | } 51 | 52 | static var opaqueSeparator: UIColor { 53 | if #available(iOS 13, *) { 54 | return .opaqueSeparator 55 | } 56 | return UIColor(red: 0.7764705882352941, green: 0.7764705882352941, blue: 0.7843137254901961, alpha: 1.0) 57 | } 58 | 59 | static var systemGray5: UIColor { 60 | if #available(iOS 13, *) { 61 | return .systemGray5 62 | } 63 | return UIColor(red: 0.8980392156862745, green: 0.8980392156862745, blue: 0.9176470588235294, alpha: 1.0) 64 | } 65 | 66 | static var systemBlue: UIColor { 67 | if #available(iOS 13, *) { 68 | return .systemBlue 69 | } 70 | return UIColor(red: 0.0, green: 0.47843137254901963, blue: 1.0, alpha: 1.0) 71 | } 72 | 73 | static var systemGreen: UIColor { 74 | if #available(iOS 13, *) { 75 | return .systemGreen 76 | } 77 | return UIColor(red: 0.20392156862745098, green: 0.7803921568627451, blue: 0.34901960784313724, alpha: 1.0) 78 | } 79 | 80 | static var systemRed: UIColor { 81 | if #available(iOS 13, *) { 82 | return .systemRed 83 | } 84 | return UIColor(red: 1.0, green: 0.23137254901960785, blue: 0.18823529411764706, alpha: 1.0) 85 | } 86 | 87 | static var systemOrange: UIColor { 88 | if #available(iOS 13, *) { 89 | return .systemOrange 90 | } 91 | return UIColor(red: 1.0, green: 0.5843137254901961, blue: 0.0, alpha: 1.0) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /CleanArchitectureExample/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ContentView: View { 12 | var body: some View { 13 | VStack { 14 | Text("Hello, World!") 15 | .bold() 16 | .italic() 17 | Text("Hello, World!") 18 | Text("Hello, World!") 19 | } 20 | } 21 | } 22 | 23 | struct ContentView_Previews: PreviewProvider { 24 | static var previews: some View { 25 | ContentView() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Data/Gateways/AuthGateway.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthGateway.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/29/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | import Factory 12 | 13 | protocol AuthGatewayProtocol { 14 | func login(username: String, password: String) -> AnyPublisher 15 | } 16 | 17 | struct AuthGateway: AuthGatewayProtocol { 18 | func login(username: String, password: String) -> AnyPublisher { 19 | return Future { promise in 20 | DispatchQueue.global().asyncAfter(deadline: .now() + 0.5, execute: { 21 | promise(.success(())) 22 | }) 23 | } 24 | .eraseToAnyPublisher() 25 | } 26 | } 27 | 28 | struct PreviewAuthGateway: AuthGatewayProtocol { 29 | func login(username: String, password: String) -> AnyPublisher { 30 | Just(()) 31 | .setFailureType(to: Error.self) 32 | .eraseToAnyPublisher() 33 | } 34 | } 35 | 36 | extension Container { 37 | var authGateway: Factory { 38 | Factory(self) { 39 | AuthGateway() 40 | } 41 | .onPreview { 42 | PreviewAuthGateway() 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Data/Gateways/GitEndpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GitEndpoint.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 26/6/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import APIService 10 | import Foundation 11 | 12 | enum GitEndpoint { 13 | case repos(page: Int, perPage: Int) 14 | case events(url: String, page: Int, perPage: Int) 15 | } 16 | 17 | extension GitEndpoint: Endpoint { 18 | var base: String? { 19 | "https://api.github.com" 20 | } 21 | 22 | var path: String? { 23 | switch self { 24 | case .repos: 25 | return "/search/repositories" 26 | default: 27 | return "" 28 | } 29 | } 30 | 31 | var urlString: String? { 32 | switch self { 33 | case .events(let url, _, _): 34 | return url 35 | default: 36 | return nil 37 | } 38 | } 39 | 40 | var queryItems: [String: Any]? { 41 | switch self { 42 | case let .repos(page, perPage): 43 | return [ 44 | "q": "language:swift", 45 | "per_page": perPage, 46 | "page": page 47 | ] 48 | case let .events(_, page, perPage): 49 | return [ 50 | "per_page": perPage, 51 | "page": page 52 | ] 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Data/Gateways/ProductGateway.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductGateway.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | import Factory 12 | 13 | protocol ProductGatewayProtocol { 14 | func getProducts() -> AnyPublisher<[Product], Error> 15 | } 16 | 17 | struct ProductGateway: ProductGatewayProtocol { 18 | func getProducts() -> AnyPublisher<[Product], Error> { 19 | Future<[Product], Error> { promise in 20 | let products = [ 21 | Product(id: 0, name: "iPhone", price: 999), 22 | Product(id: 1, name: "MacBook", price: 2999) 23 | ] 24 | 25 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 26 | promise(.success(products)) 27 | } 28 | } 29 | .eraseToAnyPublisher() 30 | } 31 | } 32 | 33 | struct PreviewProductGateway: ProductGatewayProtocol { 34 | func getProducts() -> AnyPublisher<[Product], Error> { 35 | Future<[Product], Error> { promise in 36 | let products = [ 37 | Product(id: 0, name: "iPhone", price: 999), 38 | Product(id: 1, name: "MacBook", price: 2999) 39 | ] 40 | 41 | promise(.success(products)) 42 | } 43 | .eraseToAnyPublisher() 44 | } 45 | } 46 | 47 | extension Container { 48 | var productGateway: Factory { 49 | Factory(self) { ProductGateway() } 50 | .onPreview { 51 | PreviewProductGateway() 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Data/Gateways/RepoGateway.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepoGateway.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | import Factory 12 | import APIService 13 | import CleanArchitecture 14 | 15 | protocol RepoGatewayProtocol { 16 | func getRepos(page: Int, perPage: Int) -> AnyPublisher, Error> 17 | } 18 | 19 | struct RepoGateway: RepoGatewayProtocol { 20 | private struct GetReposResult: Decodable { 21 | var items = [Repo]() 22 | } 23 | 24 | func getRepos(page: Int, perPage: Int) -> AnyPublisher, Error> { 25 | APIServices.default 26 | .request(GitEndpoint.repos(page: page, perPage: perPage)) 27 | .data(type: GetReposResult.self) 28 | .map { $0.items } 29 | .map { PagingInfo(page: page, items: $0) } 30 | .eraseToAnyPublisher() 31 | } 32 | } 33 | 34 | struct PreviewRepoGateway: RepoGatewayProtocol { 35 | func getRepos(page: Int, perPage: Int) -> AnyPublisher, Error> { 36 | Future, Error> { promise in 37 | let repos = [ 38 | Repo(id: 0, 39 | name: "SwiftUI", 40 | fullname: "SwiftUI Framework", 41 | urlString: "", 42 | starCount: 10, 43 | folkCount: 10, 44 | owner: Repo.Owner(avatarUrl: "")) 45 | ] 46 | 47 | let page = PagingInfo(page: 1, items: repos) 48 | promise(.success(page)) 49 | } 50 | .eraseToAnyPublisher() 51 | } 52 | } 53 | 54 | extension Container { 55 | var repoGateway: Factory { 56 | Factory(self) { 57 | RepoGateway() 58 | } 59 | .onPreview { 60 | PreviewRepoGateway() 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Domain/Entities/AppError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppError.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/20/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum AppError: LocalizedError { 12 | case none 13 | case error(message: String) 14 | 15 | var errorDescription: String? { 16 | switch self { 17 | case let .error(message): 18 | return message 19 | default: 20 | return "" 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Domain/Entities/Product.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Product.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | struct Product: Identifiable { 10 | var id = 0 11 | var name = "" 12 | var price = 0.0 13 | } 14 | 15 | // MARK: - Fake 16 | extension Array where Element == Product { 17 | static var fake: Self { 18 | [ 19 | Product() 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Domain/Entities/Repo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Repo.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/31/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Then 10 | import CleanArchitecture 11 | 12 | struct Repo { 13 | var id: Int? 14 | var name: String? 15 | var fullname: String? 16 | var urlString: String? 17 | var starCount: Int? 18 | var folkCount: Int? 19 | var owner: Owner? 20 | 21 | struct Owner: Decodable { 22 | var avatarUrl: String? 23 | 24 | // swiftlint:disable:next nesting 25 | private enum CodingKeys: String, CodingKey { 26 | case avatarUrl = "avatar_url" 27 | } 28 | } 29 | } 30 | 31 | extension Repo: Then, Equatable { 32 | static func == (lhs: Repo, rhs: Repo) -> Bool { 33 | return lhs.id == rhs.id 34 | } 35 | } 36 | 37 | extension Repo: Decodable { 38 | private enum CodingKeys: String, CodingKey { 39 | case id, name 40 | case fullname = "full_name" 41 | case urlString = "html_url" 42 | case starCount = "stargazers_count" 43 | case folkCount = "forks" 44 | case owner 45 | } 46 | } 47 | 48 | // MARK: - Fake 49 | extension PagingInfo where T == Repo { 50 | static var fake: Self { 51 | PagingInfo(page: 1, items: [ 52 | Repo() 53 | ]) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Domain/UseCases/Auth/LogIn.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogIn.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/29/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | 12 | protocol LogIn { 13 | var authGateway: AuthGatewayProtocol { get } 14 | } 15 | 16 | extension LogIn { 17 | func login(username: String, password: String) -> AnyPublisher { 18 | authGateway.login(username: username, password: username) 19 | } 20 | 21 | func validateUserName(_ userName: String) -> ValidationResult { 22 | if userName.isEmpty { 23 | return .failure(.init(message: "You must provide a name")) 24 | } 25 | 26 | return .success(()) 27 | } 28 | 29 | func validatePassword(_ password: String) -> ValidationResult { 30 | if password.isEmpty { 31 | return .failure(.init(message: "You must provide a password")) 32 | } 33 | 34 | return .success(()) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Domain/UseCases/Product/GetProducts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetProducts.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | 11 | protocol GetProducts { 12 | var productGateway: ProductGatewayProtocol { get } 13 | } 14 | 15 | extension GetProducts { 16 | func getProducts() -> AnyPublisher<[Product], Error> { 17 | productGateway.getProducts() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Domain/UseCases/Repo/GetRepoList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetRepoList.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import CleanArchitecture 11 | 12 | protocol GetRepoList { 13 | var repoGateway: RepoGatewayProtocol { get } 14 | } 15 | 16 | extension GetRepoList { 17 | func getRepos(page: Int, perPage: Int) -> AnyPublisher, Error> { 18 | repoGateway.getRepos(page: page, perPage: perPage) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Domain/UseCases/Validations/ValidationError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValidationError.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 17/5/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct ValidationError: LocalizedError { 12 | let message: String 13 | 14 | var errorDescription: String? { 15 | return message 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Domain/UseCases/Validations/ValidationResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValidationResult.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 17/5/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | typealias ValidationResult = Result 12 | 13 | extension Result where Failure == ValidationError { 14 | var message: String? { 15 | switch self { 16 | case .success: 17 | return nil 18 | case .failure(let error): 19 | return error.localizedDescription 20 | } 21 | } 22 | 23 | var isValid: Bool { 24 | switch self { 25 | case .success: 26 | return true 27 | case .failure: 28 | return false 29 | } 30 | } 31 | 32 | func mapToVoid() -> ValidationResult { 33 | return self.map { _ in () } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | $(MARKETING_VERSION) 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | 37 | 38 | 39 | 40 | UILaunchStoryboardName 41 | LaunchScreen 42 | UIRequiredDeviceCapabilities 43 | 44 | armv7 45 | 46 | UISupportedInterfaceOrientations 47 | 48 | UIInterfaceOrientationPortrait 49 | UIInterfaceOrientationLandscapeLeft 50 | UIInterfaceOrientationLandscapeRight 51 | 52 | UISupportedInterfaceOrientations~ipad 53 | 54 | UIInterfaceOrientationPortrait 55 | UIInterfaceOrientationPortraitUpsideDown 56 | UIInterfaceOrientationLandscapeLeft 57 | UIInterfaceOrientationLandscapeRight 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Common/ActivityIndicatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicatorView.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/22/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | 12 | struct ActivityIndicatorView: UIViewRepresentable { 13 | 14 | @Binding var isAnimating: Bool 15 | let style: UIActivityIndicatorView.Style 16 | 17 | func makeUIView(context: UIViewRepresentableContext) -> UIActivityIndicatorView { 18 | return UIActivityIndicatorView(style: style) 19 | } 20 | 21 | func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext) { 22 | if isAnimating { 23 | uiView.startAnimating() 24 | } else { 25 | uiView.stopAnimating() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Common/AlertMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlertMessage.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Then 11 | 12 | public struct AlertMessage { 13 | public var title = "" 14 | public var message = "" 15 | public var isShowing = false 16 | 17 | public init(title: String, message: String, isShowing: Bool) { 18 | self.title = title 19 | self.message = message 20 | self.isShowing = isShowing 21 | } 22 | 23 | public init() { 24 | 25 | } 26 | } 27 | 28 | public extension AlertMessage { // swiftlint:disable:this no_extension_access_modifier 29 | init(error: Error) { 30 | self.title = "Error" 31 | let message = error.localizedDescription 32 | self.message = message 33 | self.isShowing = !message.isEmpty 34 | } 35 | } 36 | 37 | extension AlertMessage: Then { } 38 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Common/BaseViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseViewController.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/4/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | import MBProgressHUD 12 | 13 | class BaseViewController: UIViewController { // swiftlint:disable:this final_class 14 | 15 | } 16 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Common/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoadingView.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/22/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct LoadingView: View where Content: View { 12 | 13 | @Binding var isShowing: Bool 14 | @Binding var text: String 15 | var content: () -> Content 16 | 17 | var body: some View { 18 | GeometryReader { _ in 19 | ZStack(alignment: .center) { 20 | 21 | self.content() 22 | .disabled(self.isShowing) 23 | // .blur(radius: self.isShowing ? 3 : 0) 24 | 25 | VStack { 26 | if !self.text.isEmpty { 27 | ActivityIndicatorView(isAnimating: .constant(true), style: .large) 28 | .padding(.top) 29 | Text(self.text) 30 | .padding([.leading, .trailing, .bottom]) 31 | } else { 32 | ActivityIndicatorView(isAnimating: .constant(true), style: .large) 33 | } 34 | } 35 | .frame(minWidth: 78, 36 | idealWidth: nil, 37 | maxWidth: nil, 38 | minHeight: 78, 39 | idealHeight: nil, 40 | maxHeight: nil, 41 | alignment: .center) 42 | .background(Color(red: 227.0 / 255.0, green: 229.0 / 255.0, blue: 230.0 / 255.0)) 43 | .foregroundColor(Color.primary) 44 | .cornerRadius(6) 45 | .opacity(self.isShowing ? 1 : 0) 46 | 47 | } 48 | } 49 | } 50 | 51 | } 52 | 53 | struct LoadingView_Previews: PreviewProvider { 54 | static var previews: some View { 55 | LoadingView(isShowing: .constant(true), text: .constant("Loading...")) { Text("Hello") } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Common/ViewDidLoadModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewDidLoadModifier.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 28/8/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ViewDidLoadModifier: ViewModifier { 12 | 13 | @State private var didLoad = false 14 | private let action: (() -> Void)? 15 | 16 | init(perform action: (() -> Void)? = nil) { 17 | self.action = action 18 | } 19 | 20 | func body(content: Content) -> some View { 21 | content.onAppear { 22 | if !didLoad { 23 | didLoad = true 24 | action?() 25 | } 26 | } 27 | } 28 | } 29 | 30 | extension View { 31 | func onLoad(perform action: (() -> Void)? = nil) -> some View { 32 | modifier(ViewDidLoadModifier(perform: action)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Login/LoginView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/29/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | import Factory 12 | import CleanArchitecture 13 | 14 | struct LoginView: View { 15 | final class Triggers: ObservableObject { 16 | var login = PassthroughSubject() 17 | } 18 | 19 | @StateObject var viewModel: LoginViewModel 20 | @StateObject var input: LoginViewModel.Input 21 | @StateObject var output: LoginViewModel.Output 22 | @StateObject var cancelBag: CancelBag 23 | @StateObject var triggers: Triggers 24 | 25 | var body: some View { 26 | LoadingView(isShowing: $output.isLoading, text: .constant("")) { 27 | VStack(alignment: .leading) { 28 | Text("User name:") 29 | TextField("", text: self.$input.username) 30 | .textFieldStyle(RoundedBorderTextFieldStyle()) 31 | .accessibilityIdentifier("userTextField") 32 | Text(self.output.usernameValidationMessage) 33 | .foregroundColor(.red) 34 | .font(.footnote) 35 | Text("Password:") 36 | .padding(.top) 37 | SecureField("", text: self.$input.password) 38 | .textFieldStyle(RoundedBorderTextFieldStyle()) 39 | .accessibilityIdentifier("passwordTextField") 40 | Text(self.output.passwordValidationMessage) 41 | .foregroundColor(.red) 42 | .font(.footnote) 43 | HStack { 44 | Spacer() 45 | Button("Login") { 46 | self.triggers.login.send(()) 47 | } 48 | .disabled(!self.output.isLoginEnabled) 49 | .padding(.top) 50 | .accessibilityIdentifier("loginButton") 51 | Spacer() 52 | } 53 | Spacer() 54 | } 55 | .padding() 56 | } 57 | .alert(isPresented: $output.alert.isShowing) { 58 | Alert( 59 | title: Text(output.alert.title), 60 | message: Text(output.alert.message), 61 | dismissButton: .default(Text("OK")) 62 | ) 63 | } 64 | } 65 | 66 | init(viewModel: LoginViewModel) { 67 | let cancelBag = CancelBag() 68 | let triggers = Triggers() 69 | let input = LoginViewModel.Input(loginTrigger: triggers.login.eraseToAnyPublisher()) 70 | let output = viewModel.transform(input, cancelBag: cancelBag) 71 | 72 | self._viewModel = StateObject(wrappedValue: viewModel) 73 | self._input = StateObject(wrappedValue: input) 74 | self._output = StateObject(wrappedValue: output) 75 | self._cancelBag = StateObject(wrappedValue: cancelBag) 76 | self._triggers = StateObject(wrappedValue: triggers) 77 | } 78 | } 79 | 80 | struct LoginView_Previews: PreviewProvider { 81 | static var previews: some View { 82 | return LoginView(viewModel: LoginViewModel()) 83 | } 84 | } 85 | 86 | extension Container { 87 | func loginView(navigationController: UINavigationController) -> Factory { 88 | Factory(self) { 89 | LoginView(viewModel: LoginViewModel()) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Login/LoginViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewModel.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | import CombineExt 12 | import Factory 13 | import CleanArchitecture 14 | 15 | class LoginViewModel: LogIn { // swiftlint:disable:this final_class 16 | @Injected(\.authGateway) 17 | var authGateway: AuthGatewayProtocol 18 | 19 | deinit { 20 | print("LoginViewModel deinit") 21 | } 22 | } 23 | 24 | // MARK: - ViewModelType 25 | extension LoginViewModel: ObservableObject, ViewModel { 26 | final class Input: ObservableObject { 27 | @Published var username = "" 28 | @Published var password = "" 29 | let loginTrigger: AnyPublisher 30 | 31 | init(loginTrigger: AnyPublisher) { 32 | self.loginTrigger = loginTrigger 33 | } 34 | } 35 | 36 | final class Output: ObservableObject { 37 | @Published var isLoginEnabled = true 38 | @Published var isLoading = false 39 | @Published var alert = AlertMessage() 40 | @Published var usernameValidationMessage = "" 41 | @Published var passwordValidationMessage = "" 42 | } 43 | 44 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 45 | let errorTracker = ErrorTracker() 46 | let activityTracker = ActivityIndicator(false) 47 | let output = Output() 48 | 49 | let usernameValidation = Publishers 50 | .CombineLatest(input.$username, input.loginTrigger) 51 | .map { $0.0 } 52 | .map(validateUserName(_:)) 53 | 54 | usernameValidation 55 | .asDriver() 56 | .map { $0.message ?? "" } 57 | .assign(to: \.usernameValidationMessage, on: output) 58 | .store(in: cancelBag) 59 | 60 | let passwordValidation = Publishers 61 | .CombineLatest(input.$password, input.loginTrigger) 62 | .map { $0.0 } 63 | .map(validatePassword(_:)) 64 | 65 | passwordValidation 66 | .asDriver() 67 | .map { $0.message ?? "" } 68 | .assign(to: \.passwordValidationMessage, on: output) 69 | .store(in: cancelBag) 70 | 71 | Publishers 72 | .CombineLatest(usernameValidation, passwordValidation) 73 | .map { $0.0.isValid && $0.1.isValid } 74 | .assign(to: \.isLoginEnabled, on: output) 75 | .store(in: cancelBag) 76 | 77 | input.loginTrigger 78 | .delay(for: 0.1, scheduler: RunLoop.main) // waiting for username/password validation 79 | .filter { output.isLoginEnabled } 80 | .map { _ in 81 | self.login(username: input.username, password: input.password) 82 | .trackError(errorTracker) 83 | .trackActivity(activityTracker) 84 | .asDriver() 85 | } 86 | .switchToLatest() 87 | .sink(receiveValue: { 88 | let message = AlertMessage(title: "Login successful", 89 | message: "Hello \(input.username). Welcome to the app!", 90 | isShowing: true) 91 | output.alert = message 92 | }) 93 | .store(in: cancelBag) 94 | 95 | errorTracker 96 | .receive(on: RunLoop.main) 97 | .map { AlertMessage(error: $0) } 98 | .assign(to: \.alert, on: output) 99 | .store(in: cancelBag) 100 | 101 | activityTracker 102 | .receive(on: RunLoop.main) 103 | .assign(to: \.isLoading, on: output) 104 | .store(in: cancelBag) 105 | 106 | return output 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Main/MainViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainViewController.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/29/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | import Then 12 | import Combine 13 | import Factory 14 | import CleanArchitecture 15 | 16 | final class MainViewController: UIViewController, Bindable { 17 | 18 | @IBOutlet weak var tableView: UITableView! 19 | 20 | var viewModel: MainViewModel! 21 | var cancelBag = CancelBag() 22 | 23 | private var output: MainViewModel.Output! 24 | private let selectMenuTrigger = PassthroughSubject() 25 | 26 | override func viewDidLoad() { 27 | super.viewDidLoad() 28 | configView() 29 | } 30 | 31 | private func configView() { 32 | tableView.do { 33 | $0.rowHeight = 60 34 | $0.register(cellType: MenuCell.self) 35 | $0.delegate = self 36 | $0.dataSource = self 37 | } 38 | 39 | title = "Templates" 40 | } 41 | 42 | func bindViewModel() { 43 | let input = MainViewModel.Input( 44 | loadTrigger: Just(()).asDriver(), 45 | selectMenuTrigger: selectMenuTrigger.asDriver() 46 | ) 47 | 48 | output = viewModel.transform(input, cancelBag: cancelBag) 49 | } 50 | 51 | } 52 | 53 | // MARK: - UITableViewDelegate 54 | extension MainViewController: UITableViewDelegate { 55 | func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 56 | return output.menuSections[section].title 57 | } 58 | 59 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 60 | tableView.deselectRow(at: indexPath, animated: true) 61 | selectMenuTrigger.send(indexPath) 62 | } 63 | } 64 | 65 | // MARK: - UITableViewDataSource 66 | extension MainViewController: UITableViewDataSource { 67 | func numberOfSections(in tableView: UITableView) -> Int { 68 | return output.menuSections.count 69 | } 70 | 71 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 72 | return output.menuSections[section].menus.count 73 | } 74 | 75 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 76 | let menu = output.menuSections[indexPath.section].menus[indexPath.row] 77 | 78 | return tableView.dequeueReusableCell(for: indexPath, cellType: MenuCell.self).then { 79 | $0.titleLabel.text = menu.description 80 | } 81 | } 82 | } 83 | 84 | // MARK: - StoryboardSceneBased 85 | extension MainViewController: StoryboardSceneBased { 86 | static var sceneStoryboard = Storyboards.main 87 | } 88 | 89 | extension Container { 90 | func mainViewController(navigationController: UINavigationController) -> Factory { 91 | Factory(self) { 92 | let vc = MainViewController.instantiate() 93 | let vm = MainViewModel(navigationController: navigationController) 94 | vc.bindViewModel(to: vm) 95 | return vc 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Main/MainViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainViewModel.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | import CleanArchitecture 12 | 13 | class MainViewModel: ShowProductList, // swiftlint:disable:this final_class 14 | ShowLogin, 15 | ShowRepoList, 16 | ShowRepoCollection { 17 | unowned let navigationController: UINavigationController 18 | 19 | init(navigationController: UINavigationController) { 20 | self.navigationController = navigationController 21 | } 22 | 23 | func vm_showProductList() { 24 | showProductList() 25 | } 26 | 27 | func vm_showLogin() { 28 | showLogin() 29 | } 30 | 31 | func vm_showRepoList() { 32 | showRepoList() 33 | } 34 | 35 | func vm_showRepoCollection() { 36 | showRepoCollection() 37 | } 38 | 39 | deinit { 40 | print("MainViewModel deinit") 41 | } 42 | } 43 | 44 | // MARK: - ViewModelType 45 | extension MainViewModel: ObservableObject, ViewModel { 46 | struct Input { 47 | let loadTrigger: AnyPublisher 48 | let selectMenuTrigger: AnyPublisher 49 | } 50 | 51 | final class Output: ObservableObject { 52 | @Published var menuSections: [MenuSection] = [] 53 | } 54 | 55 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 56 | let output = Output() 57 | 58 | input.loadTrigger 59 | .map { 60 | self.menuSections() 61 | } 62 | .assign(to: \.menuSections, on: output) 63 | .store(in: cancelBag) 64 | 65 | input.selectMenuTrigger 66 | .handleEvents(receiveOutput: { indexPath in 67 | let menu = output.menuSections[indexPath.section].menus[indexPath.row] 68 | switch menu { 69 | case .products: 70 | self.vm_showProductList() 71 | // case .sectionedProducts: 72 | // self.navigator.toSectionedProducts() 73 | case .repos: 74 | self.vm_showRepoList() 75 | case .repoCollection: 76 | self.vm_showRepoCollection() 77 | // case .users: 78 | // self.navigator.toUsers() 79 | case .login: 80 | self.vm_showLogin() 81 | } 82 | }) 83 | .sink() 84 | .store(in: cancelBag) 85 | 86 | return output 87 | } 88 | 89 | private func menuSections() -> [MenuSection] { 90 | return [ 91 | MenuSection(title: "Mock Data", menus: [.products ]), 92 | MenuSection(title: "API", menus: [.repos, .repoCollection]), 93 | // MenuSection(title: "Core Data", menus: [ .users ]), 94 | MenuSection(title: "", menus: [ .login ]) 95 | ] 96 | } 97 | } 98 | 99 | extension MainViewModel { 100 | enum Menu: Int, CustomStringConvertible, CaseIterable { 101 | case products = 0 102 | // case sectionedProducts = 1 103 | case repos = 2 104 | case repoCollection = 3 105 | // case users = 4 106 | case login = 5 107 | 108 | var description: String { 109 | switch self { 110 | case .products: 111 | return "Product list" 112 | // case .sectionedProducts: 113 | // return "Sectioned product list" 114 | case .repos: 115 | return "Git repo list" 116 | case .repoCollection: 117 | return "Git repo collection" 118 | // case .users: 119 | // return "User list" 120 | case .login: 121 | return "Login" 122 | } 123 | } 124 | } 125 | 126 | struct MenuSection { 127 | let title: String 128 | let menus: [Menu] 129 | } 130 | } 131 | 132 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Main/MenuCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuCell.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/29/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | final class MenuCell: UITableViewCell, NibReusable { 13 | @IBOutlet weak var titleLabel: UILabel! 14 | } 15 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Main/MenuCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Main/Storyboards+Main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Storyboards+Main.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 17/9/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension Storyboards { 13 | static let main = UIStoryboard(name: "Main", bundle: nil) 14 | } 15 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Navigators/ShowLogin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShowLogin.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/29/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import Factory 12 | 13 | protocol ShowLogin { 14 | var navigationController: UINavigationController { get } 15 | } 16 | 17 | extension ShowLogin { 18 | func showLogin() { 19 | let view = Container.shared.loginView(navigationController: navigationController)() 20 | let vc = UIHostingController(rootView: view) 21 | vc.title = "Login" 22 | navigationController.pushViewController(vc, animated: true) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Navigators/ShowProductDetail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShowProductDetail.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/21/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import Factory 12 | 13 | protocol ShowProductDetail { 14 | var navigationController: UINavigationController { get } 15 | } 16 | 17 | extension ShowProductDetail { 18 | func showProductDetail(product: Product) { 19 | let view = Container.shared.productDetailView(product: product)() 20 | let vc = UIHostingController(rootView: view) 21 | vc.title = "Product Detail" 22 | navigationController.pushViewController(vc, animated: true) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Navigators/ShowProductList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShowProductList.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/29/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import Factory 12 | 13 | protocol ShowProductList { 14 | var navigationController: UINavigationController { get } 15 | } 16 | 17 | extension ShowProductList { 18 | func showProductList() { 19 | let view = Container.shared.productsView(navigationController: navigationController)() 20 | let vc = UIHostingController(rootView: view) 21 | vc.title = "Product List" 22 | navigationController.pushViewController(vc, animated: true) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Navigators/ShowRepoCollection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShowRepoCollection.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/5/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Factory 11 | 12 | protocol ShowRepoCollection { 13 | var navigationController: UINavigationController { get } 14 | } 15 | 16 | extension ShowRepoCollection { 17 | func showRepoCollection() { 18 | let vc = Container.shared.repoCollectionViewController(navigationController: navigationController)() 19 | navigationController.pushViewController(vc, animated: true) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Navigators/ShowRepoDetail.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShowRepoDetail.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 14/5/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol ShowRepoDetail { 12 | 13 | } 14 | 15 | extension ShowRepoDetail { 16 | func showRepoDetail(repo: Repo) { 17 | print("Show repo detail: \(repo.name ?? "")") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Navigators/ShowRepoList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShowRepoList.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Factory 11 | 12 | protocol ShowRepoList { 13 | var navigationController: UINavigationController { get } 14 | } 15 | 16 | extension ShowRepoList { 17 | func showRepoList() { 18 | let vc = Container.shared.reposViewController(navigationController: navigationController)() 19 | navigationController.pushViewController(vc, animated: true) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/ProductDetail/ProductDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductDetailView.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/21/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | import Factory 12 | import CleanArchitecture 13 | 14 | struct ProductDetailView: View { 15 | final class Triggers: ObservableObject { 16 | var load = PassthroughSubject() 17 | } 18 | 19 | @StateObject var viewModel: ProductDetailViewModel 20 | @StateObject var output: ProductDetailViewModel.Output 21 | @StateObject var cancelBag: CancelBag 22 | @StateObject var triggers: Triggers 23 | 24 | var body: some View { 25 | VStack { 26 | HStack { 27 | Text("Product name:") 28 | Text(output.name) 29 | Spacer() 30 | } 31 | .padding([.leading, .top]) 32 | HStack { 33 | Text("Price:") 34 | Text(output.price) 35 | Spacer() 36 | } 37 | .padding([.leading, .top]) 38 | Spacer() 39 | } 40 | .onLoad { 41 | triggers.load.send(()) 42 | } 43 | } 44 | 45 | init(viewModel: ProductDetailViewModel) { 46 | let cancelBag = CancelBag() 47 | let triggers = Triggers() 48 | let input = ProductDetailViewModel.Input(loadTrigger: triggers.load.eraseToAnyPublisher()) 49 | let output = viewModel.transform(input, cancelBag: cancelBag) 50 | 51 | self._viewModel = StateObject(wrappedValue: viewModel) 52 | self._output = StateObject(wrappedValue: output) 53 | self._cancelBag = StateObject(wrappedValue: cancelBag) 54 | self._triggers = StateObject(wrappedValue: triggers) 55 | } 56 | } 57 | 58 | struct ProductDetailView_Previews: PreviewProvider { 59 | static var previews: some View { 60 | let product = Product(id: 1, name: "iPhone", price: 999) 61 | return Container.shared.productDetailView(product: product)() 62 | } 63 | } 64 | 65 | extension Container { 66 | func productDetailView(product: Product) -> Factory { 67 | Factory(self) { 68 | let vm = ProductDetailViewModel(product: product) 69 | return ProductDetailView(viewModel: vm) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/ProductDetail/ProductDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductDetailViewModel.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import CleanArchitecture 11 | 12 | final class ProductDetailViewModel { 13 | let product: Product 14 | 15 | init(product: Product) { 16 | self.product = product 17 | } 18 | 19 | deinit { 20 | print("ProductDetailViewModel deinit") 21 | } 22 | } 23 | 24 | // MARK: - ViewModel 25 | extension ProductDetailViewModel: ViewModel { 26 | struct Input { 27 | let loadTrigger: AnyPublisher 28 | } 29 | 30 | final class Output: ObservableObject { 31 | @Published var name = "" 32 | @Published var price = "" 33 | } 34 | 35 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 36 | let product = input.loadTrigger 37 | .map { 38 | self.product 39 | } 40 | 41 | let output = Output() 42 | 43 | product 44 | .map { $0.name } 45 | .assign(to: \.name, on: output) 46 | .store(in: cancelBag) 47 | 48 | product 49 | .map { $0.price.currency } 50 | .assign(to: \.price, on: output) 51 | .store(in: cancelBag) 52 | 53 | return output 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Products/ProductItemViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductItemViewModel.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/21/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | struct ProductItemViewModel { 10 | let product: Product 11 | let name: String 12 | let price: String 13 | 14 | init(product: Product) { 15 | self.product = product 16 | self.name = product.name 17 | self.price = product.price.currency 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Products/ProductRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductRow.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/21/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ProductRow: View { 12 | let viewModel: ProductItemViewModel 13 | 14 | var body: some View { 15 | HStack { 16 | Text(viewModel.name) 17 | .bold() 18 | Spacer() 19 | Text(viewModel.price) 20 | .foregroundColor(.green) 21 | } 22 | } 23 | } 24 | 25 | struct ProductRow_Previews: PreviewProvider { 26 | static var previews: some View { 27 | let product = Product(id: 1, name: "iPhone", price: 999) 28 | return ProductRow(viewModel: ProductItemViewModel(product: product)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Products/ProductsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductsView.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/20/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Combine 11 | import SwiftUIRefresh 12 | import Factory 13 | import CleanArchitecture 14 | 15 | struct ProductsView: View { 16 | final class Triggers: ObservableObject { 17 | var load = PassthroughSubject() 18 | var reload = PassthroughSubject() 19 | var select = PassthroughSubject() 20 | } 21 | 22 | @StateObject var viewModel: ProductsViewModel 23 | @StateObject var output: ProductsViewModel.Output 24 | @StateObject var cancelBag: CancelBag 25 | @StateObject var triggers: Triggers 26 | 27 | var body: some View { 28 | let products = output.products.enumerated().map { $0 } 29 | 30 | return LoadingView(isShowing: $output.isLoading, text: .constant("")) { 31 | VStack { 32 | List(products, id: \.element.name) { index, product in 33 | Button(action: { 34 | self.triggers.select.send(IndexPath(row: index, section: 0)) 35 | }) { 36 | ProductRow(viewModel: product) 37 | } 38 | } 39 | .pullToRefresh(isShowing: self.$output.isReloading) { 40 | self.triggers.reload.send(()) 41 | } 42 | } 43 | .navigationBarTitle("Product List") 44 | .navigationBarItems(trailing: Button("Refresh") { 45 | self.triggers.load.send(()) 46 | }) 47 | } 48 | .alert(isPresented: $output.alert.isShowing) { 49 | Alert( 50 | title: Text(output.alert.title), 51 | message: Text(output.alert.message), 52 | dismissButton: .default(Text("OK")) 53 | ) 54 | } 55 | .onLoad { 56 | triggers.load.send(()) 57 | } 58 | } 59 | 60 | init(viewModel: ProductsViewModel) { 61 | let triggers = Triggers() 62 | let cancelBag = CancelBag() 63 | 64 | let input = ProductsViewModel.Input( 65 | loadTrigger: triggers.load.eraseToAnyPublisher(), 66 | reloadTrigger: triggers.reload.eraseToAnyPublisher(), 67 | selectTrigger: triggers.select.eraseToAnyPublisher() 68 | ) 69 | 70 | let output = viewModel.transform(input, cancelBag: cancelBag) 71 | 72 | self._viewModel = StateObject(wrappedValue: viewModel) 73 | self._output = StateObject(wrappedValue: output) 74 | self._cancelBag = StateObject(wrappedValue: cancelBag) 75 | self._triggers = StateObject(wrappedValue: triggers) 76 | } 77 | } 78 | 79 | struct ProductsView_Preview: PreviewProvider { 80 | static var previews: some View { 81 | ProductsView(viewModel: ProductsViewModel(navigationController: UINavigationController())) 82 | } 83 | } 84 | 85 | extension Container { 86 | func productsView(navigationController: UINavigationController) -> Factory { 87 | Factory(self) { 88 | ProductsView(viewModel: ProductsViewModel(navigationController: navigationController)) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Products/ProductsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductsViewModel.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import UIKit 11 | import Factory 12 | import CleanArchitecture 13 | 14 | class ProductsViewModel: GetProducts, ShowProductDetail { // swiftlint:disable:this final_class 15 | unowned let navigationController: UINavigationController 16 | 17 | @Injected(\.productGateway) 18 | var productGateway: ProductGatewayProtocol 19 | 20 | init(navigationController: UINavigationController) { 21 | self.navigationController = navigationController 22 | } 23 | 24 | func vm_showProductDetail(product: Product) { 25 | showProductDetail(product: product) 26 | } 27 | 28 | func vm_getProducts() -> AnyPublisher<[Product], Error> { 29 | getProducts() 30 | } 31 | 32 | deinit { 33 | print("ProductsViewModel deinit") 34 | } 35 | } 36 | 37 | // MARK: - ViewModel 38 | extension ProductsViewModel: ObservableObject, ViewModel { 39 | struct Input { 40 | let loadTrigger: AnyPublisher 41 | let reloadTrigger: AnyPublisher 42 | let selectTrigger: AnyPublisher 43 | } 44 | 45 | final class Output: ObservableObject { 46 | @Published var products = [ProductItemViewModel]() 47 | @Published var isLoading = false 48 | @Published var isReloading = false 49 | @Published var alert = AlertMessage() 50 | } 51 | 52 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 53 | let config = ListFetchConfig(initialLoadTrigger: input.loadTrigger, 54 | reloadTrigger: input.reloadTrigger, 55 | fetchItems: self.vm_getProducts) 56 | 57 | let (products, error, isLoading, isReloading) = fetchList(config: config).destructured 58 | 59 | let output = Output() 60 | 61 | products 62 | .map { $0.map(ProductItemViewModel.init) } 63 | .assign(to: \.products, on: output) 64 | .store(in: cancelBag) 65 | 66 | error 67 | .receive(on: RunLoop.main) 68 | .map { AlertMessage(error: $0) } 69 | .assign(to: \.alert, on: output) 70 | .store(in: cancelBag) 71 | 72 | isLoading 73 | .assign(to: \.isLoading, on: output) 74 | .store(in: cancelBag) 75 | 76 | isReloading 77 | .assign(to: \.isReloading, on: output) 78 | .store(in: cancelBag) 79 | 80 | input.selectTrigger 81 | .sink(receiveValue: { indexPath in 82 | let product = output.products[indexPath.row].product 83 | self.vm_showProductDetail(product: product) 84 | }) 85 | .store(in: cancelBag) 86 | 87 | return output 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/RepoCollection/RepoCollectionCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepoCollectionCell.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/5/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | final class RepoCollectionCell: UICollectionViewCell, NibReusable { 13 | 14 | @IBOutlet weak var nameLabel: UILabel! 15 | @IBOutlet weak var avatarURLStringImageView: UIImageView! 16 | 17 | func bindViewModel(_ viewModel: RepoItemViewModel?) { 18 | if let viewModel = viewModel { 19 | nameLabel.text = viewModel.name 20 | avatarURLStringImageView.setImage(with: viewModel.url) 21 | } else { 22 | nameLabel.text = "" 23 | avatarURLStringImageView.image = nil 24 | } 25 | } 26 | 27 | override func prepareForReuse() { 28 | super.prepareForReuse() 29 | avatarURLStringImageView.image = nil 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/RepoCollection/RepoCollectionCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/RepoCollection/RepoCollectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepoCollectionViewController.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/5/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import ESPullToRefresh 11 | import SDWebImage 12 | import Combine 13 | import Reusable 14 | import Factory 15 | import CleanArchitecture 16 | import PagingTableView 17 | 18 | final class RepoCollectionViewController: UIViewController, Bindable { 19 | 20 | // MARK: - IBOutlets 21 | 22 | @IBOutlet weak var collectionView: PagingCollectionView! 23 | 24 | // MARK: - Properties 25 | 26 | var viewModel: ReposViewModel! 27 | var cancelBag = CancelBag() 28 | 29 | private var repos = [RepoItemViewModel]() 30 | private let selectRepoTrigger = PassthroughSubject() 31 | 32 | struct LayoutOptions { 33 | var itemSpacing: CGFloat = 16 34 | var lineSpacing: CGFloat = 16 35 | var itemsPerRow: Int = 2 36 | 37 | var sectionInsets = UIEdgeInsets( 38 | top: 16.0, 39 | left: 16.0, 40 | bottom: 16.0, 41 | right: 16.0 42 | ) 43 | 44 | var itemSize: CGSize { 45 | let screenSize = UIScreen.main.bounds 46 | 47 | let paddingSpace = sectionInsets.left 48 | + sectionInsets.right 49 | + CGFloat(itemsPerRow - 1) * itemSpacing 50 | 51 | let availableWidth = screenSize.width - paddingSpace 52 | let widthPerItem = availableWidth / CGFloat(itemsPerRow) 53 | let heightPerItem = widthPerItem 54 | 55 | return CGSize(width: widthPerItem, height: heightPerItem) 56 | } 57 | } 58 | 59 | private var layoutOptions = LayoutOptions() 60 | 61 | // MARK: - Life Cycle 62 | 63 | override func viewDidLoad() { 64 | super.viewDidLoad() 65 | configView() 66 | } 67 | 68 | override func viewWillAppear(_ animated: Bool) { 69 | super.viewWillAppear(animated) 70 | collectionView.refreshHeader = RefreshHeaderAnimator(frame: .zero) 71 | } 72 | 73 | deinit { 74 | logDeinit() 75 | } 76 | 77 | // MARK: - Methods 78 | 79 | private func configView() { 80 | collectionView.do { 81 | $0.register(cellType: RepoCollectionCell.self) 82 | $0.alwaysBounceVertical = true 83 | $0.prefetchDataSource = self 84 | $0.dataSource = self 85 | $0.delegate = self 86 | // $0.refreshHeader = RefreshAutoHeader(frame: .zero) 87 | // need to set the Estimate Size to None in the collection view size panel. 88 | } 89 | 90 | view.backgroundColor = ColorCompatibility.systemBackground 91 | collectionView.backgroundColor = ColorCompatibility.systemBackground 92 | } 93 | 94 | func bindViewModel() { 95 | let input = ReposViewModel.Input( 96 | loadTrigger: Just(()).asDriver(), 97 | reloadTrigger: collectionView.refreshTrigger, 98 | loadMoreTrigger: collectionView.loadMoreTrigger, 99 | selectRepoTrigger: selectRepoTrigger.asDriver() 100 | ) 101 | 102 | let output = viewModel.transform(input, cancelBag: cancelBag) 103 | 104 | output.$repos 105 | .sink(with: self, receiveValue: { vc, repos in 106 | vc.repos = repos 107 | vc.collectionView.reloadData() 108 | }) 109 | .store(in: cancelBag) 110 | 111 | output.$alert.subscribe(alertSubscriber) 112 | output.$isLoading.subscribe(loadingSubscriber) 113 | output.$isReloading.subscribe(collectionView.isRefreshing) 114 | output.$isLoadingMore.subscribe(collectionView.isLoadingMore) 115 | } 116 | } 117 | 118 | // MARK: - StoryboardSceneBased 119 | extension RepoCollectionViewController: StoryboardSceneBased { 120 | static var sceneStoryboard = Storyboards.repo 121 | } 122 | 123 | // MARK: - UICollectionViewDataSource 124 | extension RepoCollectionViewController: UICollectionViewDataSource { 125 | 126 | func numberOfSections(in collectionView: UICollectionView) -> Int { 127 | 1 128 | } 129 | 130 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 131 | repos.count 132 | } 133 | 134 | func collectionView(_ collectionView: UICollectionView, 135 | cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 136 | collectionView.dequeueReusableCell(for: indexPath, cellType: RepoCollectionCell.self).then { 137 | $0.bindViewModel(repos[indexPath.row]) 138 | } 139 | } 140 | } 141 | 142 | // MARK: - UICollectionViewDelegate 143 | extension RepoCollectionViewController: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { 144 | 145 | func collectionView(_ collectionView: UICollectionView, 146 | layout collectionViewLayout: UICollectionViewLayout, 147 | sizeForItemAt indexPath: IndexPath) -> CGSize { 148 | return layoutOptions.itemSize 149 | } 150 | 151 | func collectionView(_ collectionView: UICollectionView, 152 | layout collectionViewLayout: UICollectionViewLayout, 153 | insetForSectionAt section: Int) -> UIEdgeInsets { 154 | return layoutOptions.sectionInsets 155 | } 156 | 157 | func collectionView(_ collectionView: UICollectionView, 158 | layout collectionViewLayout: UICollectionViewLayout, 159 | minimumLineSpacingForSectionAt section: Int) -> CGFloat { 160 | return layoutOptions.lineSpacing 161 | } 162 | 163 | func collectionView(_ collectionView: UICollectionView, 164 | layout collectionViewLayout: UICollectionViewLayout, 165 | minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { 166 | return layoutOptions.itemSpacing 167 | } 168 | 169 | } 170 | 171 | // MARK: - UICollectionViewDataSourcePrefetching 172 | extension RepoCollectionViewController: UICollectionViewDataSourcePrefetching { 173 | func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { 174 | let urls = indexPaths 175 | .compactMap { repos[$0.row].url } 176 | 177 | print("Preheat", urls) 178 | SDWebImagePrefetcher.shared.prefetchURLs(urls) 179 | } 180 | 181 | func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { 182 | 183 | } 184 | } 185 | 186 | // MARK: - Factory 187 | extension Container { 188 | func repoCollectionViewController(navigationController: UINavigationController) 189 | -> Factory { 190 | Factory(self) { 191 | let vc = RepoCollectionViewController.instantiate() 192 | let vm = ReposViewModel() 193 | vc.bindViewModel(to: vm) 194 | return vc 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Repos/Repo.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Repos/RepoCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepoCell.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | final class RepoCell: UITableViewCell, NibReusable { 13 | @IBOutlet weak var nameLabel: UILabel! 14 | @IBOutlet weak var avatarURLStringImageView: UIImageView! 15 | 16 | func bindViewModel(_ viewModel: RepoItemViewModel?) { 17 | if let viewModel = viewModel { 18 | nameLabel.text = viewModel.name 19 | avatarURLStringImageView.setImage(with: viewModel.url) 20 | } else { 21 | nameLabel.text = "" 22 | avatarURLStringImageView.image = nil 23 | } 24 | } 25 | 26 | override func prepareForReuse() { 27 | super.prepareForReuse() 28 | avatarURLStringImageView.image = nil 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Repos/RepoCell.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Repos/RepoItemViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RepoItemViewModel.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct RepoItemViewModel { 12 | let repo: Repo 13 | let name: String 14 | let url: URL? 15 | 16 | init(repo: Repo) { 17 | self.repo = repo 18 | name = repo.name ?? "" 19 | url = URL(string: repo.owner?.avatarUrl ?? "") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Repos/ReposViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReposViewController.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | import Combine 12 | import SDWebImage 13 | import Factory 14 | import CleanArchitecture 15 | import PagingTableView 16 | 17 | final class ReposViewController: UIViewController, Bindable { 18 | 19 | // MARK: - IBOutlets 20 | @IBOutlet weak var tableView: PagingTableView! 21 | 22 | // MARK: - Properties 23 | 24 | var viewModel: ReposViewModel! 25 | var cancelBag = CancelBag() 26 | 27 | private var repos = [RepoItemViewModel]() 28 | private let selectRepoTrigger = PassthroughSubject() 29 | 30 | // MARK: - Life Cycle 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | configView() 35 | } 36 | 37 | deinit { 38 | logDeinit() 39 | } 40 | 41 | // MARK: - Methods 42 | 43 | private func configView() { 44 | title = "Repo List" 45 | 46 | tableView.do { 47 | $0.register(cellType: RepoCell.self) 48 | $0.delegate = self 49 | $0.dataSource = self 50 | $0.prefetchDataSource = self 51 | } 52 | } 53 | 54 | func bindViewModel() { 55 | let input = ReposViewModel.Input( 56 | loadTrigger: Just(()).asDriver(), 57 | reloadTrigger: tableView.refreshTrigger.print().asDriver(), 58 | loadMoreTrigger: tableView.loadMoreTrigger, 59 | selectRepoTrigger: selectRepoTrigger.asDriver() 60 | ) 61 | 62 | let output = viewModel.transform(input, cancelBag: cancelBag) 63 | 64 | output.$repos 65 | .sink(with: self, receiveValue: { vc, repos in 66 | vc.repos = repos 67 | vc.tableView.reloadData() 68 | }) 69 | .store(in: cancelBag) 70 | 71 | output.$alert.subscribe(alertSubscriber) 72 | output.$isLoading.subscribe(loadingSubscriber) 73 | output.$isReloading.subscribe(tableView.isRefreshing) 74 | output.$isLoadingMore.subscribe(tableView.isLoadingMore) 75 | } 76 | } 77 | 78 | // MARK: - UITableViewDelegate 79 | extension ReposViewController: UITableViewDelegate { 80 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 81 | tableView.deselectRow(at: indexPath, animated: true) 82 | selectRepoTrigger.send(indexPath) 83 | } 84 | 85 | func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 86 | 70 87 | } 88 | } 89 | 90 | // MARK: - UITableViewDataSource 91 | extension ReposViewController: UITableViewDataSource { 92 | func numberOfSections(in tableView: UITableView) -> Int { 93 | 1 94 | } 95 | 96 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 97 | repos.count 98 | } 99 | 100 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 101 | tableView.dequeueReusableCell(for: indexPath, cellType: RepoCell.self).then { 102 | $0.bindViewModel(repos[indexPath.row]) 103 | } 104 | } 105 | } 106 | 107 | // MARK: - UITableViewDataSourcePrefetching 108 | extension ReposViewController: UITableViewDataSourcePrefetching { 109 | func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { 110 | let urls = indexPaths 111 | .compactMap { repos[$0.row].url } 112 | 113 | print("Preheat", urls) 114 | SDWebImagePrefetcher.shared.prefetchURLs(urls) 115 | } 116 | 117 | func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { 118 | 119 | } 120 | } 121 | 122 | // MARK: - Subscribers 123 | extension ReposViewController { 124 | 125 | } 126 | 127 | // MARK: - StoryboardSceneBased 128 | extension ReposViewController: StoryboardSceneBased { 129 | static var sceneStoryboard = Storyboards.repo 130 | } 131 | 132 | // MARK: - Factory 133 | extension Container { 134 | func reposViewController(navigationController: UINavigationController) -> Factory { 135 | Factory(self) { 136 | let vc = ReposViewController.instantiate() 137 | let vm = ReposViewModel() 138 | vc.bindViewModel(to: vm) 139 | return vc 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Repos/ReposViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReposViewModel.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import UIKit 11 | import Factory 12 | import CleanArchitecture 13 | 14 | class ReposViewModel: GetRepoList, ShowRepoDetail { // swiftlint:disable:this final_class 15 | @Injected(\.repoGateway) 16 | var repoGateway: RepoGatewayProtocol 17 | 18 | // MARK: - Use cases 19 | 20 | func getRepos(page: Int) -> AnyPublisher, Error> { 21 | return getRepos(page: page, perPage: 10) 22 | } 23 | 24 | // // MARK: - Navigation 25 | 26 | func vm_showRepoDetail(repo: Repo) { 27 | showRepoDetail(repo: repo) 28 | } 29 | 30 | deinit { 31 | print("ReposViewModel deinit") 32 | } 33 | } 34 | 35 | // MARK: - ViewModel 36 | extension ReposViewModel: ObservableObject, ViewModel { 37 | struct Input { 38 | let loadTrigger: AnyPublisher 39 | let reloadTrigger: AnyPublisher 40 | let loadMoreTrigger: AnyPublisher 41 | let selectRepoTrigger: AnyPublisher 42 | } 43 | 44 | final class Output: ObservableObject { 45 | @Published var repos = [RepoItemViewModel]() 46 | @Published var isLoading = false 47 | @Published var isReloading = false 48 | @Published var isLoadingMore = false 49 | @Published var alert = AlertMessage() 50 | @Published var isEmpty = false 51 | } 52 | 53 | func transform(_ input: Input, cancelBag: CancelBag) -> Output { 54 | let output = Output() 55 | 56 | let config = PageFetchConfig(initialLoadTrigger: input.loadTrigger, 57 | reloadTrigger: input.reloadTrigger, 58 | loadMoreTrigger: input.loadMoreTrigger, 59 | fetchItems: getRepos) 60 | 61 | let (page, error, isLoading, isReloading, isLoadingMore) = fetchPage(config: config).destructured 62 | 63 | page 64 | .map { $0.items.map(RepoItemViewModel.init) } 65 | .assign(to: \.repos, on: output) 66 | .store(in: cancelBag) 67 | 68 | input.selectRepoTrigger 69 | .handleEvents(receiveOutput: { indexPath in 70 | let repo = output.repos[indexPath.row].repo 71 | self.vm_showRepoDetail(repo: repo) 72 | }) 73 | .sink() 74 | .store(in: cancelBag) 75 | 76 | error 77 | .receive(on: RunLoop.main) 78 | .map { AlertMessage(error: $0) } 79 | .assign(to: \.alert, on: output) 80 | .store(in: cancelBag) 81 | 82 | isLoading 83 | .assign(to: \.isLoading, on: output) 84 | .store(in: cancelBag) 85 | 86 | isReloading 87 | .assign(to: \.isReloading, on: output) 88 | .store(in: cancelBag) 89 | 90 | isLoadingMore 91 | .assign(to: \.isLoadingMore, on: output) 92 | .store(in: cancelBag) 93 | 94 | return output 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Repos/Storyboards+Repo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Storyboards+Repo.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 17/9/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension Storyboards { 13 | static let repo = UIStoryboard(name: "Repo", bundle: nil) 14 | } 15 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Storyboards/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Scene/Storyboards/Storyboards.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Storyboards.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/29/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | enum Storyboards { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /CleanArchitectureExample/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/14/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import Factory 12 | 13 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { 14 | 15 | var window: UIWindow? 16 | 17 | func scene(_ scene: UIScene, 18 | willConnectTo session: UISceneSession, 19 | options connectionOptions: UIScene.ConnectionOptions) { 20 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 21 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 22 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 23 | 24 | // Create the SwiftUI view that provides the window contents. 25 | let nav = UINavigationController() 26 | // let contentView: ProductsView = assembler.resolve(navigationController: nav) 27 | 28 | // Use a UIHostingController as window root view controller. 29 | if let windowScene = scene as? UIWindowScene { 30 | let window = UIWindow(windowScene: windowScene) 31 | let vc = Container.shared.mainViewController(navigationController: nav)() 32 | nav.viewControllers = [vc] 33 | window.rootViewController = nav 34 | self.window = window 35 | window.makeKeyAndVisible() 36 | } 37 | } 38 | 39 | func sceneDidDisconnect(_ scene: UIScene) { 40 | // Called as the scene is being released by the system. 41 | // This occurs shortly after the scene enters the background, or when its session is discarded. 42 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 43 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 44 | } 45 | 46 | func sceneDidBecomeActive(_ scene: UIScene) { 47 | // Called when the scene has moved from an inactive state to an active state. 48 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 49 | } 50 | 51 | func sceneWillResignActive(_ scene: UIScene) { 52 | // Called when the scene will move from an active state to an inactive state. 53 | // This may occur due to temporary interruptions (ex. an incoming phone call). 54 | } 55 | 56 | func sceneWillEnterForeground(_ scene: UIScene) { 57 | // Called as the scene transitions from the background to the foreground. 58 | // Use this method to undo the changes made on entering the background. 59 | } 60 | 61 | func sceneDidEnterBackground(_ scene: UIScene) { 62 | // Called as the scene transitions from the foreground to the background. 63 | // Use this method to save data, release shared resources, and store enough scene-specific state information 64 | // to restore the scene back to its current state. 65 | } 66 | 67 | } 68 | 69 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Support/Extensions/Double+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Double+.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 7/15/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | extension Double { 10 | var currency: String { 11 | return String(format: "$%.02f", self) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Support/Extensions/Driver.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | extension Publisher { 5 | /// Converts the publisher into a driver, ensuring events are received on the main thread and ignoring errors. 6 | /// 7 | /// - Returns: A publisher that never emits errors and receives events on the main thread. 8 | public func asDriver() -> AnyPublisher { 9 | self.catch { _ in Empty() } // Ignore errors 10 | .receive(on: RunLoop.main) // Receive events on the main thread 11 | .share(replay: 1) // Share the subscription among multiple subscribers with replay 12 | .eraseToAnyPublisher() // Erase to AnyPublisher 13 | } 14 | 15 | /// Converts the publisher into a driver, ensuring events are received on the main thread and replacing errors with a default value. 16 | /// 17 | /// - Parameter defaultValue: The default value to emit in case of errors. 18 | /// - Returns: A publisher that never emits errors and receives events on the main thread. 19 | public func asDriver(defaultValue: Output) -> AnyPublisher { 20 | self.replaceError(with: defaultValue) // Replace errors with a default value 21 | .receive(on: RunLoop.main) // Receive events on the main thread 22 | .share(replay: 1) // Share the subscription among multiple subscribers with replay 23 | .eraseToAnyPublisher() // Erase to AnyPublisher 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Support/Extensions/Observable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | extension Publisher { 5 | public func asObservable() -> AnyPublisher { 6 | self.mapError { $0 } 7 | .eraseToAnyPublisher() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Support/Extensions/Publisher+.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension Publisher { 4 | public func sink() -> AnyCancellable { 5 | return self.sink(receiveCompletion: { _ in }, receiveValue: { _ in }) 6 | } 7 | } 8 | 9 | extension Publisher { 10 | func sink(with object: Object, 11 | receiveCompletion: @escaping ((Object, Subscribers.Completion) -> Void), 12 | receiveValue: @escaping ((Object, Self.Output) -> Void)) -> AnyCancellable { 13 | weak var weakObject = object 14 | 15 | return sink { completion in 16 | guard let object = weakObject else { return } 17 | receiveCompletion(object, completion) 18 | } receiveValue: { output in 19 | guard let object = weakObject else { return } 20 | receiveValue(object, output) 21 | } 22 | } 23 | } 24 | 25 | extension Publisher where Self.Failure == Never { 26 | public func sink(with object: Object, 27 | receiveValue: @escaping ((Object, Self.Output) -> Void)) -> AnyCancellable { 28 | weak var weakObject = object 29 | 30 | return sink { output in 31 | guard let object = weakObject else { return } 32 | receiveValue(object, output) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Support/Extensions/UIImageView+SDWebImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImageView+SDWebImage.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SDWebImage 11 | 12 | extension UIImageView { 13 | func setImage(with url: URL?, completion: (() -> Void)? = nil) { 14 | self.sd_setImage(with: url, placeholderImage: nil, options: .refreshCached) { (_, _, _, _) in 15 | completion?() 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Support/Extensions/UIViewController+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIViewController { 12 | func showAlert(_ alert: AlertMessage, completion: (() -> Void)? = nil) { 13 | let ac = UIAlertController(title: alert.title, 14 | message: alert.message, 15 | preferredStyle: .alert) 16 | let okAction = UIAlertAction(title: "OK", style: .cancel) { _ in 17 | completion?() 18 | } 19 | ac.addAction(okAction) 20 | present(ac, animated: true, completion: nil) 21 | } 22 | 23 | func showError(_ error: Error, completion: (() -> Void)? = nil) { 24 | let ac = UIAlertController(title: "Error", 25 | message: error.localizedDescription, 26 | preferredStyle: .alert) 27 | let okAction = UIAlertAction(title: "OK", style: .cancel) { _ in 28 | completion?() 29 | } 30 | ac.addAction(okAction) 31 | present(ac, animated: true, completion: nil) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Support/Extensions/UIViewController+Combine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+Combine.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Combine 11 | import MBProgressHUD 12 | import CleanArchitecture 13 | 14 | extension UIViewController { 15 | var alertSubscriber: GenericSubscriber { 16 | GenericSubscriber(self) { (vc, alertMessage) in 17 | vc.showAlert(alertMessage) 18 | } 19 | } 20 | 21 | var loadingSubscriber: GenericSubscriber { 22 | GenericSubscriber(self) { (vc, isLoading) in 23 | if isLoading { 24 | let hud = MBProgressHUD.showAdded(to: vc.view, animated: true) 25 | hud.offset.y = -30 26 | } else { 27 | MBProgressHUD.hide(for: vc.view, animated: true) 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /CleanArchitectureExample/Support/Extensions/UIViewController+Debug.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIViewController+Debug.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Tuan Truong on 8/3/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIViewController { 12 | public func logDeinit() { 13 | print(String(describing: type(of: self)) + " deinit") 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Data/Gateways/MockAuthGateway.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockAuthGateway.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 8/11/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | @testable import CleanArchitectureExample 10 | import UIKit 11 | import Combine 12 | 13 | final class MockAuthGateway: AuthGatewayProtocol { 14 | 15 | // MARK: - login 16 | 17 | var loginCalled = false 18 | var loginReturnValue: Result = .success(()) 19 | 20 | func login(username: String, password: String) -> AnyPublisher { 21 | loginCalled = true 22 | return loginReturnValue.publisher.eraseToAnyPublisher() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Data/Gateways/MockProductGateway.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockProductGateway.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 14/5/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | @testable import CleanArchitectureExample 10 | import Foundation 11 | import Combine 12 | import CleanArchitecture 13 | 14 | final class FakeProductGateway: ProductGatewayProtocol { 15 | // swiftlint:disable:next unavailable_function 16 | func getProducts() -> AnyPublisher<[CleanArchitectureExample.Product], Error> { 17 | fatalError("N/A") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Data/Gateways/MockRepoGateway.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockRepoGateway.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 14/5/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | @testable import CleanArchitectureExample 10 | import UIKit 11 | import Combine 12 | import CleanArchitecture 13 | 14 | final class MockRepoGateway: RepoGatewayProtocol { 15 | var getReposCalled = false 16 | var getReposResult: Result, Error> = .success(PagingInfo.fake) 17 | 18 | func getRepos(page: Int, perPage: Int) -> AnyPublisher, Error> { 19 | getReposCalled = true 20 | return getReposResult.publisher.eraseToAnyPublisher() 21 | } 22 | } 23 | 24 | final class RepoGatewayFake: RepoGatewayProtocol { 25 | // swiftlint:disable:next unavailable_function 26 | func getRepos(page: Int, perPage: Int) -> AnyPublisher, Error> { 27 | fatalError("N/A") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Domain/UseCases/LogInTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LogInTests.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 8/11/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | @testable import CleanArchitectureExample 10 | import XCTest 11 | import CleanArchitecture 12 | 13 | final class LogInTests: XCTestCase, LogIn { 14 | var authGateway: AuthGatewayProtocol { 15 | return authGatewayMock 16 | } 17 | 18 | private var authGatewayMock = MockAuthGateway() 19 | private var cancelBag: CancelBag! 20 | 21 | override func setUpWithError() throws { 22 | cancelBag = CancelBag() 23 | } 24 | 25 | func test_login() { 26 | let result = expectValue(of: self.login(username: "username", password: "password"), 27 | equals: [ { _ in true } ]) 28 | wait(for: [result.expectation], timeout: 1) 29 | } 30 | 31 | func test_login_failed() { 32 | authGatewayMock.loginReturnValue = .failure(TestError()) 33 | 34 | let result = expectFailure(of: self.login(username: "user", password: "password")) 35 | wait(for: [result.expectation], timeout: 1) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Extensions/XCTestCase+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCase+.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 8/10/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Combine 11 | 12 | extension XCTestCase { 13 | func wait(interval: TimeInterval = 0.1, completion: @escaping (() -> Void)) { 14 | let exp = expectation(description: "") 15 | 16 | DispatchQueue.main.asyncAfter(deadline: .now() + interval) { 17 | completion() 18 | exp.fulfill() 19 | } 20 | 21 | waitForExpectations(timeout: interval + 0.1) // add 0.1 for sure async after called 22 | } 23 | } 24 | 25 | extension XCTestCase { 26 | typealias CompletionResult = (expectation: XCTestExpectation, cancellable: AnyCancellable) 27 | 28 | func expectCompletion(of publisher: T, 29 | timeout: TimeInterval = 2, 30 | file: StaticString = #file, 31 | line: UInt = #line) -> CompletionResult { 32 | let exp = expectation(description: "Successful completion of " + String(describing: publisher)) 33 | 34 | let cancellable = publisher 35 | .sink(receiveCompletion: { completion in 36 | if case .finished = completion { 37 | exp.fulfill() 38 | } 39 | }, receiveValue: { _ in }) 40 | 41 | return (exp, cancellable) 42 | } 43 | 44 | func expectFailure(of publisher: T, 45 | timeout: TimeInterval = 2, 46 | file: StaticString = #file, 47 | line: UInt = #line) -> CompletionResult { 48 | let exp = expectation(description: "Failure completion of " + String(describing: publisher)) 49 | 50 | let cancellable = publisher 51 | .sink(receiveCompletion: { completion in 52 | if case .failure = completion { 53 | exp.fulfill() 54 | } 55 | }, receiveValue: { _ in }) 56 | 57 | return (exp, cancellable) 58 | } 59 | 60 | func expectValue(of publisher: T, 61 | timeout: TimeInterval = 2, 62 | file: StaticString = #file, 63 | line: UInt = #line, 64 | equals: [(T.Output) -> Bool]) -> CompletionResult { 65 | let exp = expectation(description: "Correct values of " + String(describing: publisher)) 66 | var mutableEquals = equals 67 | let cancellable = publisher 68 | .sink(receiveCompletion: { _ in }, 69 | receiveValue: { value in 70 | if mutableEquals.first?(value) ?? false { 71 | _ = mutableEquals.remove(at: 0) 72 | if mutableEquals.isEmpty { 73 | exp.fulfill() 74 | } 75 | } 76 | }) 77 | return (exp, cancellable) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Scene/Main/MainViewControllerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainViewControllerTests.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 8/10/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | @testable import CleanArchitectureExample 10 | import XCTest 11 | import Reusable 12 | 13 | final class MainViewControllerTests: XCTestCase { 14 | var viewController: MainViewController! 15 | 16 | override func setUp() { 17 | super.setUp() 18 | viewController = MainViewController.instantiate() 19 | } 20 | 21 | func test_ibOutlets() { 22 | _ = viewController.view 23 | XCTAssertNotNil(viewController.tableView) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Scene/Main/MainViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainViewModelTests.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 8/10/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | @testable import CleanArchitectureExample 10 | import XCTest 11 | import Combine 12 | import CleanArchitecture 13 | 14 | final class MainViewModelTests: XCTestCase { 15 | private var viewModel: TestMainViewModel! 16 | 17 | private var input: MainViewModel.Input! 18 | private var output: MainViewModel.Output! 19 | private var cancelBag: CancelBag! 20 | 21 | private let loadTrigger = PassthroughSubject() 22 | private let selectMenuTrigger = PassthroughSubject() 23 | 24 | override func setUp() { 25 | super.setUp() 26 | viewModel = TestMainViewModel(navigationController: UINavigationController()) 27 | cancelBag = CancelBag() 28 | 29 | input = MainViewModel.Input(loadTrigger: loadTrigger.asDriver(), 30 | selectMenuTrigger: selectMenuTrigger.asDriver()) 31 | output = viewModel.transform(input, cancelBag: cancelBag) 32 | } 33 | 34 | func test_load_menu() { 35 | // act 36 | loadTrigger.send(()) 37 | 38 | // assert 39 | wait { 40 | XCTAssertEqual(self.output.menuSections.count, 3) 41 | } 42 | } 43 | 44 | func test_selectMenu_toProducts() { 45 | // act 46 | loadTrigger.send(()) 47 | selectMenuTrigger.send(IndexPath(row: 0, section: 0)) 48 | 49 | // assert 50 | wait { 51 | XCTAssert(self.viewModel.showProductListCalled) 52 | } 53 | } 54 | } 55 | 56 | final class TestMainViewModel: MainViewModel { 57 | var showProductListCalled = false 58 | 59 | override func vm_showProductList() { 60 | showProductListCalled = true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Scene/Products/ProductsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductsViewModelTests.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 8/11/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | @testable import CleanArchitectureExample 10 | import XCTest 11 | import Combine 12 | import CleanArchitecture 13 | 14 | final class ProductsViewModelTests: XCTestCase { 15 | private var viewModel: TestProductsViewModel! 16 | 17 | private var input: ProductsViewModel.Input! 18 | private var output: ProductsViewModel.Output! 19 | private var loadTrigger = PassthroughSubject() 20 | private var reloadTrigger = PassthroughSubject() 21 | private var selectTrigger = PassthroughSubject() 22 | 23 | private var cancelBag: CancelBag! 24 | 25 | override func setUp() { 26 | super.setUp() 27 | viewModel = TestProductsViewModel(navigationController: UINavigationController()) 28 | 29 | input = ProductsViewModel.Input(loadTrigger: loadTrigger.eraseToAnyPublisher(), 30 | reloadTrigger: reloadTrigger.eraseToAnyPublisher(), 31 | selectTrigger: selectTrigger.eraseToAnyPublisher()) 32 | 33 | cancelBag = CancelBag() 34 | output = viewModel.transform(input, cancelBag: cancelBag) 35 | } 36 | 37 | func test_loadTrigger_getProducts() { 38 | // act 39 | loadTrigger.send(()) 40 | 41 | // assert 42 | wait { 43 | XCTAssert(self.viewModel.getProductsCalled) 44 | XCTAssertEqual(self.output.products.count, 1) 45 | } 46 | } 47 | 48 | func test_loadTrigger_failed_showError() { 49 | // arrange 50 | viewModel.getProductsReturnValue = .failure(TestError()) 51 | 52 | // act 53 | loadTrigger.send(()) 54 | 55 | // assert 56 | wait { 57 | XCTAssert(self.viewModel.getProductsCalled) 58 | XCTAssert(self.output.alert.isShowing) 59 | } 60 | } 61 | 62 | func test_reloadTrigger_getProducts() { 63 | // act 64 | reloadTrigger.send(()) 65 | 66 | // assert 67 | wait { 68 | XCTAssert(self.viewModel.getProductsCalled) 69 | XCTAssertEqual(self.output.products.count, 1) 70 | } 71 | } 72 | 73 | func test_reloadTrigger_failed_showError() { 74 | // arrange 75 | viewModel.getProductsReturnValue = .failure(TestError()) 76 | 77 | // act 78 | reloadTrigger.send(()) 79 | 80 | // assert 81 | wait { 82 | XCTAssert(self.viewModel.getProductsCalled) 83 | XCTAssert(self.output.alert.isShowing) 84 | } 85 | } 86 | 87 | func test_selectTrigger_showProductDetail() { 88 | // act 89 | loadTrigger.send(()) 90 | selectTrigger.send(IndexPath(row: 0, section: 0)) 91 | 92 | // assert 93 | wait { 94 | XCTAssert(self.viewModel.showProductDetailCalled) 95 | } 96 | } 97 | } 98 | 99 | final class TestProductsViewModel: ProductsViewModel { 100 | var getProductsCalled = false 101 | var getProductsReturnValue: Result<[Product], Error> = .success([Product].fake) 102 | var showProductDetailCalled = false 103 | 104 | override func vm_getProducts() -> AnyPublisher<[Product], Error> { 105 | getProductsCalled = true 106 | return getProductsReturnValue.publisher.eraseToAnyPublisher() 107 | } 108 | 109 | override func vm_showProductDetail(product: Product) { 110 | showProductDetailCalled = true 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/Scene/Repos/ReposViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReposViewModelTests.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 14/5/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | @testable import CleanArchitectureExample 10 | import XCTest 11 | import Combine 12 | import CleanArchitecture 13 | 14 | final class ReposViewModelTests: XCTestCase { 15 | private var viewModel: TestReposViewModel! 16 | private var cancelBag = CancelBag() 17 | private var output: ReposViewModel.Output! 18 | 19 | private var loadTrigger = PassthroughSubject() 20 | private var reloadTrigger = PassthroughSubject() 21 | private var loadMoreTrigger = PassthroughSubject() 22 | private var selectRepoTrigger = PassthroughSubject() 23 | 24 | override func setUpWithError() throws { 25 | viewModel = TestReposViewModel() 26 | cancelBag = CancelBag() 27 | 28 | let input = ReposViewModel.Input( 29 | loadTrigger: loadTrigger.eraseToAnyPublisher(), 30 | reloadTrigger: reloadTrigger.eraseToAnyPublisher(), 31 | loadMoreTrigger: loadMoreTrigger.eraseToAnyPublisher(), 32 | selectRepoTrigger: selectRepoTrigger.eraseToAnyPublisher() 33 | ) 34 | 35 | output = viewModel.transform(input, cancelBag: cancelBag) 36 | } 37 | 38 | func test_loadTrigger_getRepos() { 39 | // act 40 | loadTrigger.send(()) 41 | 42 | // assert 43 | wait { 44 | XCTAssert(self.viewModel.getReposCalled) 45 | XCTAssertEqual(self.output.repos.count, 1) 46 | } 47 | } 48 | } 49 | 50 | final class TestReposViewModel: ReposViewModel { 51 | var vmShowRepoDetailCalled = false 52 | var getReposCalled = false 53 | var getReposReturnValue: Result, Error> = .success(PagingInfo.fake) 54 | 55 | override func vm_showRepoDetail(repo: Repo) { 56 | vmShowRepoDetailCalled = true 57 | } 58 | 59 | override func getRepos(page: Int) -> AnyPublisher, Error> { 60 | getReposCalled = true 61 | return getReposReturnValue.publisher.eraseToAnyPublisher() 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /CleanArchitectureExampleTests/TestError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestError.swift 3 | // CleanArchitectureTests 4 | // 5 | // Created by Tuan Truong on 8/11/20. 6 | // Copyright © 2020 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct TestError: Error { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /CleanArchitectureExampleUITests/LoginUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginUITests.swift 3 | // CleanArchitectureUITests 4 | // 5 | // Created by Tuan Truong on 17/9/24. 6 | // Copyright © 2024 Tuan Truong. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import UIKit 11 | 12 | final class LoginUITests: XCTestCase { 13 | let app = XCUIApplication() 14 | 15 | override func setUp() { 16 | super.setUp() 17 | app.launch() 18 | } 19 | 20 | override func tearDown() { 21 | super.tearDown() 22 | app.terminate() 23 | } 24 | 25 | private func showLogin() { 26 | let app = XCUIApplication() 27 | app.launch() 28 | 29 | let tableView = app.tables.element(boundBy: 0) 30 | _ = tableView.waitForExistence(timeout: 2) 31 | 32 | let cell = tableView.cells.element(boundBy: 3) 33 | XCTAssertTrue(cell.exists, "The cell in section 3 should exist") 34 | cell.tap() 35 | 36 | let navigationBar = app.navigationBars.element(boundBy: 0) 37 | _ = navigationBar.waitForExistence(timeout: 2) 38 | XCTAssertTrue(navigationBar.exists, "The navigation bar should exist") 39 | 40 | let expectedTitle = "Login" 41 | XCTAssertEqual(navigationBar.staticTexts.element.label, expectedTitle, "The screen title is incorrect") 42 | } 43 | 44 | @MainActor 45 | func testLoginSuccess() { 46 | showLogin() 47 | 48 | let userTextField = app.textFields["userTextField"] 49 | let passwordTextField = app.secureTextFields["passwordTextField"] 50 | let loginButton = app.buttons["loginButton"] 51 | 52 | // Input valid user 53 | userTextField.tap() 54 | userTextField.typeText("user") 55 | 56 | // Input valid password 57 | passwordTextField.tap() 58 | passwordTextField.typeText("validpassword") 59 | 60 | // Tap login button 61 | loginButton.tap() 62 | 63 | // Login success 64 | let alert = app.alerts["Login successful"] 65 | let exists = alert.waitForExistence(timeout: 2) 66 | XCTAssertTrue(exists, "The alert should appear on the screen") 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tuan Truong 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CleanArchitecture", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | .library( 13 | name: "CleanArchitecture", 14 | targets: ["CleanArchitecture"]), 15 | .library( 16 | name: "PagingTableView", 17 | targets: ["PagingTableView"]) 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/CombineCommunity/CombineCocoa.git", from: "0.4.1"), 21 | .package(url: "https://github.com/eggswift/pull-to-refresh.git", from: "2.9.3") 22 | ], 23 | targets: [ 24 | .target( 25 | name: "CleanArchitecture", 26 | path: "CleanArchitecture/Sources" 27 | ), 28 | .target( 29 | name: "PagingTableView", 30 | dependencies: [ 31 | "CleanArchitecture", 32 | .product(name: "CombineCocoa", package: "CombineCocoa"), 33 | .product(name: "ESPullToRefresh", package: "pull-to-refresh") 34 | ], 35 | path: "PagingTableView/Sources" 36 | ) 37 | ], 38 | swiftLanguageVersions: [.v5] 39 | ) 40 | -------------------------------------------------------------------------------- /PagingTableView/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /PagingTableView/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "PagingTableView", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, making them visible to other packages. 13 | .library( 14 | name: "PagingTableView", 15 | targets: ["PagingTableView"]), 16 | ], 17 | dependencies: [ 18 | .package(name: "CleanArchitecture", path: "../CleanArchitecture"), 19 | .package(url: "https://github.com/CombineCommunity/CombineCocoa.git", from: "0.4.1"), 20 | .package(url: "https://github.com/eggswift/pull-to-refresh.git", from: "2.9.3") 21 | ], 22 | targets: [ 23 | // Targets are the basic building blocks of a package, defining a module or a test suite. 24 | // Targets can depend on other targets in this package and products from dependencies. 25 | .target( 26 | name: "PagingTableView", 27 | dependencies: [ 28 | .product(name: "CleanArchitecture", package: "CleanArchitecture"), 29 | .product(name: "CombineCocoa", package: "CombineCocoa"), 30 | .product(name: "ESPullToRefresh", package: "pull-to-refresh") 31 | ]), 32 | 33 | ] 34 | ) 35 | 36 | -------------------------------------------------------------------------------- /PagingTableView/Sources/PagingTableView/PagingCollectionView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import ESPullToRefresh 3 | import Combine 4 | import CombineCocoa 5 | import CleanArchitecture 6 | 7 | /// A `UICollectionView` subclass that provides pull-to-refresh and load-more functionality. 8 | open class PagingCollectionView: UICollectionView { 9 | 10 | private let _refreshControl = UIRefreshControl() 11 | private var _refreshTrigger = PassthroughSubject() 12 | private var _loadMoreTrigger = PassthroughSubject() 13 | 14 | /// A subscriber that controls the refreshing state of the collection view. 15 | /// - When `true`, the refresh control begins refreshing; when `false`, it stops. 16 | /// - If a custom refresh header is set, it uses the ESPullToRefresh control instead of the default UIRefreshControl. 17 | open var isRefreshing: GenericSubscriber { 18 | GenericSubscriber(self) { collectionView, loading in 19 | if collectionView.refreshHeader == nil { 20 | if loading { 21 | collectionView._refreshControl.beginRefreshing() 22 | } else if collectionView._refreshControl.isRefreshing { 23 | collectionView._refreshControl.endRefreshing() 24 | } 25 | } else { 26 | loading ? collectionView.es.startPullToRefresh() : collectionView.es.stopPullToRefresh() 27 | } 28 | } 29 | } 30 | 31 | /// A subscriber that controls the loading more state of the collection view. 32 | /// - When `true`, the load-more control begins refreshing; when `false`, it stops. 33 | open var isLoadingMore: GenericSubscriber { 34 | GenericSubscriber(self) { collectionView, loading in 35 | loading ? collectionView.es.base.footer?.startRefreshing() : collectionView.es.stopLoadingMore() 36 | } 37 | } 38 | 39 | /// A publisher that emits events when a refresh is triggered. 40 | /// - Emits a `Void` event whenever a refresh action is triggered, whether through a system control or programmatically. 41 | open var refreshTrigger: AnyPublisher { 42 | Publishers.Merge( 43 | _refreshTrigger 44 | .filter { [weak self] in self?.refreshControl == nil }, 45 | _refreshControl.isRefreshingPublisher 46 | .filter { [weak self] in $0 && self?.refreshControl != nil } 47 | .map { _ in } 48 | ) 49 | .eraseToAnyPublisher() 50 | } 51 | 52 | /// A publisher that emits events when loading more is triggered. 53 | /// - Emits a `Void` event whenever the load-more functionality is activated. 54 | open var loadMoreTrigger: AnyPublisher { 55 | _loadMoreTrigger.eraseToAnyPublisher() 56 | } 57 | 58 | /// The custom refresh header for pull-to-refresh functionality, conforming to both `ESRefreshProtocol` and `ESRefreshAnimatorProtocol`. 59 | /// - When set, this header replaces the default `UIRefreshControl` and enables customized pull-to-refresh animations and behaviors. 60 | open var refreshHeader: (ESRefreshProtocol & ESRefreshAnimatorProtocol)? { 61 | didSet { 62 | guard let header = refreshHeader else { return } 63 | es.addPullToRefresh(animator: header) { [weak self] in 64 | self?._refreshTrigger.send(()) 65 | } 66 | removeRefreshControl() 67 | } 68 | } 69 | 70 | /// The custom refresh footer for load-more functionality, conforming to both `ESRefreshProtocol` and `ESRefreshAnimatorProtocol`. 71 | /// - When set, this footer enables load-more functionality at the bottom of the collection view. 72 | open var refreshFooter: (ESRefreshProtocol & ESRefreshAnimatorProtocol)? { 73 | didSet { 74 | guard let footer = refreshFooter else { return } 75 | es.addInfiniteScrolling(animator: footer) { [weak self] in 76 | self?._loadMoreTrigger.send() 77 | } 78 | } 79 | } 80 | 81 | /// Initializes the collection view and sets up default refresh and load-more controls. 82 | override open func awakeFromNib() { 83 | super.awakeFromNib() 84 | addRefreshControl() 85 | refreshFooter = RefreshFooterAnimator(frame: .zero) 86 | } 87 | 88 | /// Adds the default `UIRefreshControl` to the collection view for pull-to-refresh functionality. 89 | /// - This method is called by `awakeFromNib` and can be used to re-enable the system refresh control if needed. 90 | open func addRefreshControl() { 91 | refreshHeader = nil 92 | self.refreshControl = _refreshControl 93 | } 94 | 95 | /// Removes the default `UIRefreshControl` from the collection view. 96 | /// - This method is called when a custom `refreshHeader` is set to replace the default control. 97 | open func removeRefreshControl() { 98 | self.refreshControl = nil 99 | } 100 | 101 | /// Removes the custom refresh header from the collection view. 102 | open func removeRefreshHeader() { 103 | self.es.removeRefreshHeader() 104 | } 105 | 106 | /// Removes the custom refresh footer from the collection view. 107 | open func removeRefreshFooter() { 108 | self.es.removeRefreshFooter() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /PagingTableView/Sources/PagingTableView/PagingTableView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import ESPullToRefresh 3 | import Combine 4 | import CombineCocoa 5 | import CleanArchitecture 6 | 7 | /// A `UITableView` subclass that integrates pull-to-refresh and load-more functionality. 8 | open class PagingTableView: UITableView { 9 | 10 | private let _refreshControl = UIRefreshControl() 11 | private var _refreshTrigger = PassthroughSubject() 12 | private var _loadMoreTrigger = PassthroughSubject() 13 | 14 | /// A subscriber that controls the refreshing state of the table view. 15 | /// - The subscriber will begin or end the refreshing state based on the provided `Bool` value. 16 | /// - When `true`, the refresh control will start refreshing. When `false`, the refresh control will stop. 17 | open var isRefreshing: GenericSubscriber { 18 | GenericSubscriber(self) { tableView, loading in 19 | if tableView.refreshHeader == nil { 20 | if loading { 21 | tableView._refreshControl.beginRefreshing() 22 | } else { 23 | if tableView._refreshControl.isRefreshing { 24 | tableView._refreshControl.endRefreshing() 25 | } 26 | } 27 | } else { 28 | if loading { 29 | tableView.es.startPullToRefresh() 30 | } else { 31 | tableView.es.stopPullToRefresh() 32 | } 33 | } 34 | } 35 | } 36 | 37 | /// A subscriber that controls the loading more state of the table view. 38 | /// - The subscriber will start or stop the loading-more state based on the provided `Bool` value. 39 | /// - When `true`, the load-more control will start refreshing. When `false`, it will stop. 40 | open var isLoadingMore: GenericSubscriber { 41 | return GenericSubscriber(self) { tableView, loading in 42 | if loading { 43 | tableView.es.base.footer?.startRefreshing() 44 | } else { 45 | tableView.es.stopLoadingMore() 46 | } 47 | } 48 | } 49 | 50 | /// A publisher that emits events when a refresh is triggered. 51 | /// - Emits a `Void` event when a refresh is initiated, either through a user action or programmatically. 52 | /// - This publisher uses a combination of `_refreshTrigger` and `UIRefreshControl`'s `isRefreshing` state. 53 | open var refreshTrigger: AnyPublisher { 54 | return Publishers.Merge( 55 | _refreshTrigger 56 | .filter { [weak self] in 57 | self?.refreshControl == nil 58 | }, 59 | _refreshControl.isRefreshingPublisher 60 | .filter { [weak self] in 61 | $0 && self?.refreshControl != nil 62 | } 63 | .map { _ in } 64 | ) 65 | .eraseToAnyPublisher() 66 | } 67 | 68 | /// A publisher that emits events when loading more is triggered. 69 | /// - Emits a `Void` event when the load-more functionality is initiated, typically when reaching the end of the table view content. 70 | open var loadMoreTrigger: AnyPublisher { 71 | _loadMoreTrigger.eraseToAnyPublisher() 72 | } 73 | 74 | /// The custom refresh header for pull-to-refresh functionality, conforming to both `ESRefreshProtocol` and `ESRefreshAnimatorProtocol`. 75 | /// - When set, this header replaces the default `UIRefreshControl` and adds custom pull-to-refresh functionality. 76 | open var refreshHeader: (ESRefreshProtocol & ESRefreshAnimatorProtocol)? { 77 | didSet { 78 | guard let header = refreshHeader else { return } 79 | es.addPullToRefresh(animator: header) { [weak self] in 80 | self?._refreshTrigger.send(()) 81 | } 82 | removeRefreshControl() 83 | } 84 | } 85 | 86 | /// The custom refresh footer for load-more functionality, conforming to both `ESRefreshProtocol` and `ESRefreshAnimatorProtocol`. 87 | /// - When set, this footer enables load-more functionality at the bottom of the table view. 88 | open var refreshFooter: (ESRefreshProtocol & ESRefreshAnimatorProtocol)? { 89 | didSet { 90 | guard let footer = refreshFooter else { return } 91 | es.addInfiniteScrolling(animator: footer) { [weak self] in 92 | self?._loadMoreTrigger.send() 93 | } 94 | } 95 | } 96 | 97 | /// Initializes the table view and sets up default refresh and load-more behaviors. 98 | override open func awakeFromNib() { 99 | super.awakeFromNib() 100 | expiredTimeInterval = 20.0 101 | addRefreshControl() 102 | refreshFooter = RefreshFooterAnimator(frame: .zero) 103 | } 104 | 105 | /// Adds the default `UIRefreshControl` to the table view for pull-to-refresh functionality. 106 | /// - This method is called by `awakeFromNib` and can be used to re-enable the system refresh control. 107 | open func addRefreshControl() { 108 | refreshHeader = nil 109 | self.refreshControl = _refreshControl 110 | } 111 | 112 | /// Removes the default `UIRefreshControl` from the table view. 113 | /// - This method is called when a custom `refreshHeader` is set to replace the default control. 114 | open func removeRefreshControl() { 115 | self.refreshControl = nil 116 | } 117 | 118 | /// Removes the custom refresh header from the table view. 119 | open func removeRefreshHeader() { 120 | self.es.removeRefreshHeader() 121 | } 122 | 123 | /// Removes the custom refresh footer from the table view. 124 | open func removeRefreshFooter() { 125 | self.es.removeRefreshFooter() 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /PagingTableView/Sources/PagingTableView/RefreshFooterAnimator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import ESPullToRefresh 3 | 4 | open class RefreshFooterAnimator: UIView, ESRefreshProtocol, ESRefreshAnimatorProtocol { 5 | 6 | open var view: UIView { return self } 7 | open var duration: TimeInterval = 0.3 8 | open var insets = UIEdgeInsets.zero 9 | open var trigger: CGFloat = 42.0 10 | open var executeIncremental: CGFloat = 42.0 11 | open var state: ESRefreshViewState = .pullToRefresh 12 | 13 | fileprivate let indicatorView: UIActivityIndicatorView = { 14 | let indicatorView = UIActivityIndicatorView(style: .medium) 15 | indicatorView.isHidden = true 16 | return indicatorView 17 | }() 18 | 19 | override public init(frame: CGRect) { 20 | super.init(frame: frame) 21 | addSubview(indicatorView) 22 | } 23 | 24 | // swiftlint:disable:next unavailable_function 25 | public required init(coder aDecoder: NSCoder) { 26 | fatalError("init(coder:) has not been implemented") 27 | } 28 | 29 | open func refreshAnimationBegin(view: ESRefreshComponent) { 30 | indicatorView.startAnimating() 31 | indicatorView.isHidden = false 32 | } 33 | 34 | open func refreshAnimationEnd(view: ESRefreshComponent) { 35 | indicatorView.stopAnimating() 36 | indicatorView.isHidden = true 37 | } 38 | 39 | open func refresh(view: ESRefreshComponent, progressDidChange progress: CGFloat) { 40 | // do nothing 41 | } 42 | 43 | open func refresh(view: ESRefreshComponent, stateDidChange state: ESRefreshViewState) { 44 | guard self.state != state else { 45 | return 46 | } 47 | self.state = state 48 | self.setNeedsLayout() 49 | } 50 | 51 | override open func layoutSubviews() { 52 | super.layoutSubviews() 53 | let s = self.bounds.size 54 | let w = s.width 55 | let h = s.height 56 | 57 | indicatorView.center = CGPoint(x: w / 2.0, y: h / 2.0 - 5.0) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /PagingTableView/Sources/PagingTableView/RefreshHeaderAnimator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import ESPullToRefresh 3 | 4 | open class RefreshHeaderAnimator: UIView, ESRefreshProtocol, ESRefreshAnimatorProtocol, ESRefreshImpactProtocol { 5 | open var view: UIView { return self } 6 | open var insets = UIEdgeInsets.zero 7 | open var trigger: CGFloat = 60.0 8 | open var executeIncremental: CGFloat = 60.0 9 | open var state: ESRefreshViewState = .pullToRefresh 10 | 11 | fileprivate let imageView: UIImageView = { 12 | let imageView = UIImageView() 13 | let frameworkBundle = Bundle(for: ESRefreshAnimator.self) 14 | if /* CocoaPods static */ let path = frameworkBundle.path(forResource: "ESPullToRefresh", ofType: "bundle"), 15 | let bundle = Bundle(path: path) { 16 | imageView.image = UIImage(named: "icon_pull_to_refresh_arrow", in: bundle, compatibleWith: nil) 17 | } else if /* Carthage */ let bundle = Bundle(identifier: "com.eggswift.ESPullToRefresh") { 18 | imageView.image = UIImage(named: "icon_pull_to_refresh_arrow", in: bundle, compatibleWith: nil) 19 | } else if /* CocoaPods */ let bundle = Bundle(identifier: "org.cocoapods.ESPullToRefresh") { 20 | imageView.image = UIImage(named: "ESPullToRefresh.bundle/icon_pull_to_refresh_arrow", 21 | in: bundle, 22 | compatibleWith: nil) 23 | } else /* Manual */ { 24 | imageView.image = UIImage(named: "icon_pull_to_refresh_arrow") 25 | } 26 | return imageView 27 | }() 28 | 29 | fileprivate let titleLabel: UILabel = { 30 | let label = UILabel(frame: CGRect.zero) 31 | label.font = UIFont.systemFont(ofSize: 14.0) 32 | label.textColor = UIColor(white: 0.625, alpha: 1.0) 33 | label.textAlignment = .left 34 | return label 35 | }() 36 | 37 | fileprivate let indicatorView: UIActivityIndicatorView = { 38 | let indicatorView = UIActivityIndicatorView(style: .medium) 39 | indicatorView.isHidden = true 40 | return indicatorView 41 | }() 42 | 43 | override public init(frame: CGRect) { 44 | super.init(frame: frame) 45 | self.addSubview(imageView) 46 | self.addSubview(titleLabel) 47 | self.addSubview(indicatorView) 48 | } 49 | 50 | // swiftlint:disable:next unavailable_function 51 | public required init(coder aDecoder: NSCoder) { 52 | fatalError("init(coder:) has not been implemented") 53 | } 54 | 55 | open func refreshAnimationBegin(view: ESRefreshComponent) { 56 | indicatorView.startAnimating() 57 | indicatorView.isHidden = false 58 | imageView.isHidden = true 59 | imageView.transform = CGAffineTransform(rotationAngle: 0.000001 - CGFloat.pi) 60 | } 61 | 62 | open func refreshAnimationEnd(view: ESRefreshComponent) { 63 | indicatorView.stopAnimating() 64 | indicatorView.isHidden = true 65 | imageView.isHidden = false 66 | imageView.transform = CGAffineTransform.identity 67 | } 68 | 69 | open func refresh(view: ESRefreshComponent, progressDidChange progress: CGFloat) { 70 | // Do nothing 71 | 72 | } 73 | 74 | open func refresh(view: ESRefreshComponent, stateDidChange state: ESRefreshViewState) { 75 | guard self.state != state else { 76 | return 77 | } 78 | self.state = state 79 | 80 | switch state { 81 | case .refreshing, .autoRefreshing: 82 | self.setNeedsLayout() 83 | case .releaseToRefresh: 84 | self.setNeedsLayout() 85 | self.impact() 86 | UIView.animate( 87 | withDuration: 0.2, 88 | delay: 0.0, 89 | options: UIView.AnimationOptions(), 90 | animations: { [weak self] in 91 | self?.imageView.transform = CGAffineTransform(rotationAngle: 0.000001 - CGFloat.pi) 92 | }) 93 | case .pullToRefresh: 94 | self.setNeedsLayout() 95 | UIView.animate( 96 | withDuration: 0.2, 97 | delay: 0.0, 98 | options: UIView.AnimationOptions(), 99 | animations: { [weak self] in 100 | self?.imageView.transform = CGAffineTransform.identity 101 | }) 102 | default: 103 | break 104 | } 105 | } 106 | 107 | override open func layoutSubviews() { 108 | super.layoutSubviews() 109 | let s = self.bounds.size 110 | let w = s.width 111 | let h = s.height 112 | 113 | UIView.performWithoutAnimation { 114 | indicatorView.center = CGPoint(x: w / 2.0, y: h / 2.0) 115 | imageView.frame = CGRect(x: titleLabel.frame.origin.x - 28.0, 116 | y: (h - 18.0) / 2.0, 117 | width: 18.0, 118 | height: 18.0) 119 | } 120 | } 121 | 122 | } 123 | -------------------------------------------------------------------------------- /files/xcode_project_template.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/files/xcode_project_template.zip -------------------------------------------------------------------------------- /files/xcode_templates.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/files/xcode_templates.zip -------------------------------------------------------------------------------- /igen.config: -------------------------------------------------------------------------------- 1 | [project] 2 | name = CleanArchitecture 3 | developer = Tuan Truong 4 | company = Sun Asterisk 5 | 6 | [output] 7 | path = /Users/tuan/Desktop/ 8 | 9 | -------------------------------------------------------------------------------- /images/bridging_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/bridging_header.png -------------------------------------------------------------------------------- /images/copy_files.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/copy_files.png -------------------------------------------------------------------------------- /images/create_bridging_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/create_bridging_header.png -------------------------------------------------------------------------------- /images/data.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/data.png -------------------------------------------------------------------------------- /images/delete_files.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/delete_files.png -------------------------------------------------------------------------------- /images/dependency_direction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/dependency_direction.png -------------------------------------------------------------------------------- /images/detail_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/detail_overview.png -------------------------------------------------------------------------------- /images/domain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/domain.png -------------------------------------------------------------------------------- /images/drag_files_folders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/drag_files_folders.png -------------------------------------------------------------------------------- /images/example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/example.jpg -------------------------------------------------------------------------------- /images/high_level_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/high_level_overview.png -------------------------------------------------------------------------------- /images/mvvm_pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/mvvm_pattern.png -------------------------------------------------------------------------------- /images/new_project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/new_project.png -------------------------------------------------------------------------------- /images/presentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/presentation.png -------------------------------------------------------------------------------- /images/remove_scene_configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/remove_scene_configuration.png -------------------------------------------------------------------------------- /images/result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/result.png -------------------------------------------------------------------------------- /images/skeleton.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/skeleton.png -------------------------------------------------------------------------------- /images/swiftlint_run_script.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/swiftlint_run_script.png -------------------------------------------------------------------------------- /images/template_scene_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/template_scene_name.png -------------------------------------------------------------------------------- /images/templates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/templates.png -------------------------------------------------------------------------------- /images/xcode_templates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan188/CleanArchitecture/fc3f7791b72a2d15d6efa5a6efc1a227fc34399f/images/xcode_templates.png -------------------------------------------------------------------------------- /xcode_project_template.md: -------------------------------------------------------------------------------- 1 | # Import Clean Architecture Project Template for Xcode 2 | 3 | Download [Xcode project template](files/xcode_project_template.zip) and unzip to folder: 4 | 5 | ``` 6 | $ open ~/Library/Developer/Xcode/Templates/Project\ Templates 7 | ``` 8 | 9 | When creating a new project, please add the following two SPM libraries to the project: 10 | 11 | - https://github.com/tuan188/CleanArchitecture 12 | - https://github.com/hmlongco/Factory 13 | 14 | -------------------------------------------------------------------------------- /xcode_templates.md: -------------------------------------------------------------------------------- 1 | # Import Clean Architecture Templates for Xcode 2 | 3 | Download [Xcode template files](files/xcode_templates.zip) and unzip to folder: 4 | 5 | ``` 6 | $ open ~/Library/Developer/Xcode/Templates/File\ Templates/Custom\ Templates 7 | ``` 8 | 9 | --------------------------------------------------------------------------------