├── .gitignore ├── CleanArchitecture.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── CleanArchitecture.xcscheme │ └── CleanArchitectureTests.xcscheme ├── CleanArchitecture ├── App │ ├── Common │ │ ├── Base │ │ │ └── ViewModelType.swift │ │ ├── Resolver.swift │ │ ├── Rx │ │ │ ├── ActivityIndicator.swift │ │ │ └── ErrorTracker.swift │ │ ├── Storyboard.swift │ │ └── Views │ │ │ ├── Alert.swift │ │ │ ├── Loading.swift │ │ │ └── Toast.swift │ ├── Extensions │ │ ├── UITableView+Extensions.swift │ │ ├── UITableView+Rx.swift │ │ └── ViewState+Rx.swift │ ├── Scenes │ │ └── Article │ │ │ ├── Article.storyboard │ │ │ ├── ArticleDetail │ │ │ ├── ArticleDetailViewController.swift │ │ │ └── ArticleDetailViewModel.swift │ │ │ ├── ArticleList │ │ │ ├── ArticleListViewController.swift │ │ │ ├── ArticleListViewModel.swift │ │ │ └── Views │ │ │ │ └── ArticleCell.swift │ │ │ └── ArticleNavigator.swift │ └── Theme │ │ └── Color.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard ├── Data │ ├── Config │ │ └── Config.swift │ ├── Network │ │ ├── ArticleService.swift │ │ ├── Mock │ │ │ └── searchArticles.json │ │ └── RestAPI.swift │ └── Repositories │ │ └── ArticleRepository.swift ├── Domain │ ├── Entities │ │ ├── Article.swift │ │ └── CodableDefault.swift │ └── UseCases │ │ └── ArticleUseCase.swift └── Info.plist ├── CleanArchitectureTests ├── Helpers │ └── MockLoader.swift ├── Info.plist ├── RepositoryMocks │ ├── ArticleRepositoryMock.swift │ └── Mock │ │ └── searchArticles.json └── ViewModelTests │ └── ArticleListViewModelTests.swift ├── CleanArchitectureUITests ├── CleanArchitectureUITests.swift └── Info.plist ├── Images ├── DetailLevel.png └── HighLevel.png ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /CleanArchitecture.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 1463DD1C25E36E82004BEE4F /* RxTest in Frameworks */ = {isa = PBXBuildFile; productRef = 1463DD1B25E36E82004BEE4F /* RxTest */; }; 11 | 1463DD2125E36E96004BEE4F /* RxBlocking in Frameworks */ = {isa = PBXBuildFile; productRef = 1463DD2025E36E96004BEE4F /* RxBlocking */; }; 12 | 1463DD2625E38CAB004BEE4F /* CodableDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1463DD2525E38CAB004BEE4F /* CodableDefault.swift */; }; 13 | 147E75F825E77957001C9FE6 /* a.html in Resources */ = {isa = PBXBuildFile; fileRef = 147E75F725E77957001C9FE6 /* a.html */; }; 14 | F4E2BDC725E2B7B600F7A304 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = F4E2BDC625E2B7B600F7A304 /* Alamofire */; }; 15 | F4E2BDCD25E2B9A100F7A304 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = F4E2BDCC25E2B9A100F7A304 /* Kingfisher */; }; 16 | F4E2BE0225E2BA1600F7A304 /* ArticleListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDD825E2BA1600F7A304 /* ArticleListViewController.swift */; }; 17 | F4E2BE0325E2BA1600F7A304 /* ArticleListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDD925E2BA1600F7A304 /* ArticleListViewModel.swift */; }; 18 | F4E2BE0425E2BA1600F7A304 /* ArticleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDDB25E2BA1600F7A304 /* ArticleCell.swift */; }; 19 | F4E2BE0525E2BA1600F7A304 /* ArticleDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDDD25E2BA1600F7A304 /* ArticleDetailViewModel.swift */; }; 20 | F4E2BE0625E2BA1600F7A304 /* ArticleDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDDE25E2BA1600F7A304 /* ArticleDetailViewController.swift */; }; 21 | F4E2BE0725E2BA1600F7A304 /* Article.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F4E2BDDF25E2BA1600F7A304 /* Article.storyboard */; }; 22 | F4E2BE0825E2BA1600F7A304 /* ArticleNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDE025E2BA1600F7A304 /* ArticleNavigator.swift */; }; 23 | F4E2BE0925E2BA1600F7A304 /* UITableView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDE225E2BA1600F7A304 /* UITableView+Extensions.swift */; }; 24 | F4E2BE0A25E2BA1600F7A304 /* UITableView+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDE325E2BA1600F7A304 /* UITableView+Rx.swift */; }; 25 | F4E2BE0B25E2BA1600F7A304 /* ViewState+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDE425E2BA1600F7A304 /* ViewState+Rx.swift */; }; 26 | F4E2BE0C25E2BA1600F7A304 /* Resolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDE625E2BA1600F7A304 /* Resolver.swift */; }; 27 | F4E2BE0D25E2BA1600F7A304 /* Storyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDE725E2BA1600F7A304 /* Storyboard.swift */; }; 28 | F4E2BE0E25E2BA1600F7A304 /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDE925E2BA1600F7A304 /* Alert.swift */; }; 29 | F4E2BE0F25E2BA1600F7A304 /* Loading.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDEA25E2BA1600F7A304 /* Loading.swift */; }; 30 | F4E2BE1025E2BA1600F7A304 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDEB25E2BA1600F7A304 /* Toast.swift */; }; 31 | F4E2BE1125E2BA1600F7A304 /* ErrorTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDED25E2BA1600F7A304 /* ErrorTracker.swift */; }; 32 | F4E2BE1225E2BA1600F7A304 /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDEE25E2BA1600F7A304 /* ActivityIndicator.swift */; }; 33 | F4E2BE1325E2BA1600F7A304 /* ViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDF025E2BA1600F7A304 /* ViewModelType.swift */; }; 34 | F4E2BE1425E2BA1600F7A304 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDF225E2BA1600F7A304 /* Color.swift */; }; 35 | F4E2BE1525E2BA1600F7A304 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDF525E2BA1600F7A304 /* Config.swift */; }; 36 | F4E2BE1625E2BA1600F7A304 /* searchArticles.json in Resources */ = {isa = PBXBuildFile; fileRef = F4E2BDF825E2BA1600F7A304 /* searchArticles.json */; }; 37 | F4E2BE1725E2BA1600F7A304 /* RestAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDF925E2BA1600F7A304 /* RestAPI.swift */; }; 38 | F4E2BE1825E2BA1600F7A304 /* ArticleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDFA25E2BA1600F7A304 /* ArticleService.swift */; }; 39 | F4E2BE1925E2BA1600F7A304 /* ArticleRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDFC25E2BA1600F7A304 /* ArticleRepository.swift */; }; 40 | F4E2BE1A25E2BA1600F7A304 /* ArticleUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BDFF25E2BA1600F7A304 /* ArticleUseCase.swift */; }; 41 | F4E2BE1B25E2BA1600F7A304 /* Article.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BE0125E2BA1600F7A304 /* Article.swift */; }; 42 | F4E2BE2425E2BAA600F7A304 /* Differentiator in Frameworks */ = {isa = PBXBuildFile; productRef = F4E2BE2325E2BAA600F7A304 /* Differentiator */; }; 43 | F4E2BE2625E2BAA600F7A304 /* RxDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = F4E2BE2525E2BAA600F7A304 /* RxDataSources */; }; 44 | F4E2BE7F25E2C4CD00F7A304 /* ArticleListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BE7825E2C4CD00F7A304 /* ArticleListViewModelTests.swift */; }; 45 | F4E2BE8025E2C4CD00F7A304 /* MockLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BE7A25E2C4CD00F7A304 /* MockLoader.swift */; }; 46 | F4E2BE8125E2C4CD00F7A304 /* searchArticles.json in Resources */ = {isa = PBXBuildFile; fileRef = F4E2BE7D25E2C4CD00F7A304 /* searchArticles.json */; }; 47 | F4E2BE8225E2C4CD00F7A304 /* ArticleRepositoryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E2BE7E25E2C4CD00F7A304 /* ArticleRepositoryMock.swift */; }; 48 | F4ECBE9A25E2A0720054DD81 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4ECBE9925E2A0720054DD81 /* AppDelegate.swift */; }; 49 | F4ECBEA125E2A0720054DD81 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F4ECBE9F25E2A0720054DD81 /* Main.storyboard */; }; 50 | F4ECBEA325E2A0730054DD81 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F4ECBEA225E2A0730054DD81 /* Assets.xcassets */; }; 51 | F4ECBEA625E2A0730054DD81 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F4ECBEA425E2A0730054DD81 /* LaunchScreen.storyboard */; }; 52 | F4ECBEBC25E2A0730054DD81 /* CleanArchitectureUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4ECBEBB25E2A0730054DD81 /* CleanArchitectureUITests.swift */; }; 53 | F4ECBED125E2B61F0054DD81 /* RxSwift in Frameworks */ = {isa = PBXBuildFile; productRef = F4ECBED025E2B61F0054DD81 /* RxSwift */; }; 54 | F4ECBED525E2B61F0054DD81 /* RxRelay in Frameworks */ = {isa = PBXBuildFile; productRef = F4ECBED425E2B61F0054DD81 /* RxRelay */; }; 55 | F4ECBED725E2B61F0054DD81 /* RxCocoa in Frameworks */ = {isa = PBXBuildFile; productRef = F4ECBED625E2B61F0054DD81 /* RxCocoa */; }; 56 | /* End PBXBuildFile section */ 57 | 58 | /* Begin PBXContainerItemProxy section */ 59 | F4ECBEAD25E2A0730054DD81 /* PBXContainerItemProxy */ = { 60 | isa = PBXContainerItemProxy; 61 | containerPortal = F4ECBE8E25E2A0720054DD81 /* Project object */; 62 | proxyType = 1; 63 | remoteGlobalIDString = F4ECBE9525E2A0720054DD81; 64 | remoteInfo = CleanArchitecture; 65 | }; 66 | F4ECBEB825E2A0730054DD81 /* PBXContainerItemProxy */ = { 67 | isa = PBXContainerItemProxy; 68 | containerPortal = F4ECBE8E25E2A0720054DD81 /* Project object */; 69 | proxyType = 1; 70 | remoteGlobalIDString = F4ECBE9525E2A0720054DD81; 71 | remoteInfo = CleanArchitecture; 72 | }; 73 | /* End PBXContainerItemProxy section */ 74 | 75 | /* Begin PBXFileReference section */ 76 | 1463DD2525E38CAB004BEE4F /* CodableDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableDefault.swift; sourceTree = ""; }; 77 | 147E75F725E77957001C9FE6 /* a.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = a.html; sourceTree = ""; }; 78 | F4E2BDD825E2BA1600F7A304 /* ArticleListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleListViewController.swift; sourceTree = ""; }; 79 | F4E2BDD925E2BA1600F7A304 /* ArticleListViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleListViewModel.swift; sourceTree = ""; }; 80 | F4E2BDDB25E2BA1600F7A304 /* ArticleCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleCell.swift; sourceTree = ""; }; 81 | F4E2BDDD25E2BA1600F7A304 /* ArticleDetailViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleDetailViewModel.swift; sourceTree = ""; }; 82 | F4E2BDDE25E2BA1600F7A304 /* ArticleDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleDetailViewController.swift; sourceTree = ""; }; 83 | F4E2BDDF25E2BA1600F7A304 /* Article.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Article.storyboard; sourceTree = ""; }; 84 | F4E2BDE025E2BA1600F7A304 /* ArticleNavigator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleNavigator.swift; sourceTree = ""; }; 85 | F4E2BDE225E2BA1600F7A304 /* UITableView+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+Extensions.swift"; sourceTree = ""; }; 86 | F4E2BDE325E2BA1600F7A304 /* UITableView+Rx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITableView+Rx.swift"; sourceTree = ""; }; 87 | F4E2BDE425E2BA1600F7A304 /* ViewState+Rx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ViewState+Rx.swift"; sourceTree = ""; }; 88 | F4E2BDE625E2BA1600F7A304 /* Resolver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Resolver.swift; sourceTree = ""; }; 89 | F4E2BDE725E2BA1600F7A304 /* Storyboard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storyboard.swift; sourceTree = ""; }; 90 | F4E2BDE925E2BA1600F7A304 /* Alert.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = ""; }; 91 | F4E2BDEA25E2BA1600F7A304 /* Loading.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Loading.swift; sourceTree = ""; }; 92 | F4E2BDEB25E2BA1600F7A304 /* Toast.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = ""; }; 93 | F4E2BDED25E2BA1600F7A304 /* ErrorTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorTracker.swift; sourceTree = ""; }; 94 | F4E2BDEE25E2BA1600F7A304 /* ActivityIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; 95 | F4E2BDF025E2BA1600F7A304 /* ViewModelType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewModelType.swift; sourceTree = ""; }; 96 | F4E2BDF225E2BA1600F7A304 /* Color.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; 97 | F4E2BDF525E2BA1600F7A304 /* Config.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 98 | F4E2BDF825E2BA1600F7A304 /* searchArticles.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = searchArticles.json; sourceTree = ""; }; 99 | F4E2BDF925E2BA1600F7A304 /* RestAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RestAPI.swift; sourceTree = ""; }; 100 | F4E2BDFA25E2BA1600F7A304 /* ArticleService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleService.swift; sourceTree = ""; }; 101 | F4E2BDFC25E2BA1600F7A304 /* ArticleRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleRepository.swift; sourceTree = ""; }; 102 | F4E2BDFF25E2BA1600F7A304 /* ArticleUseCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleUseCase.swift; sourceTree = ""; }; 103 | F4E2BE0125E2BA1600F7A304 /* Article.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Article.swift; sourceTree = ""; }; 104 | F4E2BE7825E2C4CD00F7A304 /* ArticleListViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleListViewModelTests.swift; sourceTree = ""; }; 105 | F4E2BE7A25E2C4CD00F7A304 /* MockLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockLoader.swift; sourceTree = ""; }; 106 | F4E2BE7D25E2C4CD00F7A304 /* searchArticles.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = searchArticles.json; sourceTree = ""; }; 107 | F4E2BE7E25E2C4CD00F7A304 /* ArticleRepositoryMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArticleRepositoryMock.swift; sourceTree = ""; }; 108 | F4ECBE9625E2A0720054DD81 /* CleanArchitecture.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CleanArchitecture.app; sourceTree = BUILT_PRODUCTS_DIR; }; 109 | F4ECBE9925E2A0720054DD81 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 110 | F4ECBEA025E2A0720054DD81 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 111 | F4ECBEA225E2A0730054DD81 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 112 | F4ECBEA525E2A0730054DD81 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 113 | F4ECBEA725E2A0730054DD81 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 114 | F4ECBEAC25E2A0730054DD81 /* CleanArchitectureTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CleanArchitectureTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 115 | F4ECBEB225E2A0730054DD81 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 116 | F4ECBEB725E2A0730054DD81 /* CleanArchitectureUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CleanArchitectureUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 117 | F4ECBEBB25E2A0730054DD81 /* CleanArchitectureUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanArchitectureUITests.swift; sourceTree = ""; }; 118 | F4ECBEBD25E2A0730054DD81 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 119 | /* End PBXFileReference section */ 120 | 121 | /* Begin PBXFrameworksBuildPhase section */ 122 | F4ECBE9325E2A0720054DD81 /* Frameworks */ = { 123 | isa = PBXFrameworksBuildPhase; 124 | buildActionMask = 2147483647; 125 | files = ( 126 | 1463DD1C25E36E82004BEE4F /* RxTest in Frameworks */, 127 | 1463DD2125E36E96004BEE4F /* RxBlocking in Frameworks */, 128 | F4ECBED525E2B61F0054DD81 /* RxRelay in Frameworks */, 129 | F4E2BDCD25E2B9A100F7A304 /* Kingfisher in Frameworks */, 130 | F4ECBED725E2B61F0054DD81 /* RxCocoa in Frameworks */, 131 | F4E2BE2425E2BAA600F7A304 /* Differentiator in Frameworks */, 132 | F4E2BDC725E2B7B600F7A304 /* Alamofire in Frameworks */, 133 | F4E2BE2625E2BAA600F7A304 /* RxDataSources in Frameworks */, 134 | F4ECBED125E2B61F0054DD81 /* RxSwift in Frameworks */, 135 | ); 136 | runOnlyForDeploymentPostprocessing = 0; 137 | }; 138 | F4ECBEA925E2A0730054DD81 /* Frameworks */ = { 139 | isa = PBXFrameworksBuildPhase; 140 | buildActionMask = 2147483647; 141 | files = ( 142 | ); 143 | runOnlyForDeploymentPostprocessing = 0; 144 | }; 145 | F4ECBEB425E2A0730054DD81 /* Frameworks */ = { 146 | isa = PBXFrameworksBuildPhase; 147 | buildActionMask = 2147483647; 148 | files = ( 149 | ); 150 | runOnlyForDeploymentPostprocessing = 0; 151 | }; 152 | /* End PBXFrameworksBuildPhase section */ 153 | 154 | /* Begin PBXGroup section */ 155 | 1463DD0C25E36E3C004BEE4F /* Frameworks */ = { 156 | isa = PBXGroup; 157 | children = ( 158 | ); 159 | name = Frameworks; 160 | sourceTree = ""; 161 | }; 162 | F4E2BDD425E2BA1600F7A304 /* App */ = { 163 | isa = PBXGroup; 164 | children = ( 165 | F4E2BDE525E2BA1600F7A304 /* Common */, 166 | F4E2BDE125E2BA1600F7A304 /* Extensions */, 167 | F4E2BDF125E2BA1600F7A304 /* Theme */, 168 | F4E2BDD525E2BA1600F7A304 /* Scenes */, 169 | ); 170 | path = App; 171 | sourceTree = ""; 172 | }; 173 | F4E2BDD525E2BA1600F7A304 /* Scenes */ = { 174 | isa = PBXGroup; 175 | children = ( 176 | F4E2BDD625E2BA1600F7A304 /* Article */, 177 | ); 178 | path = Scenes; 179 | sourceTree = ""; 180 | }; 181 | F4E2BDD625E2BA1600F7A304 /* Article */ = { 182 | isa = PBXGroup; 183 | children = ( 184 | F4E2BDD725E2BA1600F7A304 /* ArticleList */, 185 | F4E2BDDC25E2BA1600F7A304 /* ArticleDetail */, 186 | F4E2BDDF25E2BA1600F7A304 /* Article.storyboard */, 187 | F4E2BDE025E2BA1600F7A304 /* ArticleNavigator.swift */, 188 | ); 189 | path = Article; 190 | sourceTree = ""; 191 | }; 192 | F4E2BDD725E2BA1600F7A304 /* ArticleList */ = { 193 | isa = PBXGroup; 194 | children = ( 195 | F4E2BDD825E2BA1600F7A304 /* ArticleListViewController.swift */, 196 | F4E2BDD925E2BA1600F7A304 /* ArticleListViewModel.swift */, 197 | F4E2BDDA25E2BA1600F7A304 /* Views */, 198 | ); 199 | path = ArticleList; 200 | sourceTree = ""; 201 | }; 202 | F4E2BDDA25E2BA1600F7A304 /* Views */ = { 203 | isa = PBXGroup; 204 | children = ( 205 | F4E2BDDB25E2BA1600F7A304 /* ArticleCell.swift */, 206 | ); 207 | path = Views; 208 | sourceTree = ""; 209 | }; 210 | F4E2BDDC25E2BA1600F7A304 /* ArticleDetail */ = { 211 | isa = PBXGroup; 212 | children = ( 213 | F4E2BDDD25E2BA1600F7A304 /* ArticleDetailViewModel.swift */, 214 | F4E2BDDE25E2BA1600F7A304 /* ArticleDetailViewController.swift */, 215 | ); 216 | path = ArticleDetail; 217 | sourceTree = ""; 218 | }; 219 | F4E2BDE125E2BA1600F7A304 /* Extensions */ = { 220 | isa = PBXGroup; 221 | children = ( 222 | F4E2BDE225E2BA1600F7A304 /* UITableView+Extensions.swift */, 223 | F4E2BDE325E2BA1600F7A304 /* UITableView+Rx.swift */, 224 | F4E2BDE425E2BA1600F7A304 /* ViewState+Rx.swift */, 225 | ); 226 | path = Extensions; 227 | sourceTree = ""; 228 | }; 229 | F4E2BDE525E2BA1600F7A304 /* Common */ = { 230 | isa = PBXGroup; 231 | children = ( 232 | F4E2BDE625E2BA1600F7A304 /* Resolver.swift */, 233 | 147E75F725E77957001C9FE6 /* a.html */, 234 | F4E2BDE725E2BA1600F7A304 /* Storyboard.swift */, 235 | F4E2BDE825E2BA1600F7A304 /* Views */, 236 | F4E2BDEC25E2BA1600F7A304 /* Rx */, 237 | F4E2BDEF25E2BA1600F7A304 /* Base */, 238 | ); 239 | path = Common; 240 | sourceTree = ""; 241 | }; 242 | F4E2BDE825E2BA1600F7A304 /* Views */ = { 243 | isa = PBXGroup; 244 | children = ( 245 | F4E2BDE925E2BA1600F7A304 /* Alert.swift */, 246 | F4E2BDEA25E2BA1600F7A304 /* Loading.swift */, 247 | F4E2BDEB25E2BA1600F7A304 /* Toast.swift */, 248 | ); 249 | path = Views; 250 | sourceTree = ""; 251 | }; 252 | F4E2BDEC25E2BA1600F7A304 /* Rx */ = { 253 | isa = PBXGroup; 254 | children = ( 255 | F4E2BDED25E2BA1600F7A304 /* ErrorTracker.swift */, 256 | F4E2BDEE25E2BA1600F7A304 /* ActivityIndicator.swift */, 257 | ); 258 | path = Rx; 259 | sourceTree = ""; 260 | }; 261 | F4E2BDEF25E2BA1600F7A304 /* Base */ = { 262 | isa = PBXGroup; 263 | children = ( 264 | F4E2BDF025E2BA1600F7A304 /* ViewModelType.swift */, 265 | ); 266 | path = Base; 267 | sourceTree = ""; 268 | }; 269 | F4E2BDF125E2BA1600F7A304 /* Theme */ = { 270 | isa = PBXGroup; 271 | children = ( 272 | F4E2BDF225E2BA1600F7A304 /* Color.swift */, 273 | ); 274 | path = Theme; 275 | sourceTree = ""; 276 | }; 277 | F4E2BDF325E2BA1600F7A304 /* Data */ = { 278 | isa = PBXGroup; 279 | children = ( 280 | F4E2BDF425E2BA1600F7A304 /* Config */, 281 | F4E2BDF625E2BA1600F7A304 /* Network */, 282 | F4E2BDFB25E2BA1600F7A304 /* Repositories */, 283 | ); 284 | path = Data; 285 | sourceTree = ""; 286 | }; 287 | F4E2BDF425E2BA1600F7A304 /* Config */ = { 288 | isa = PBXGroup; 289 | children = ( 290 | F4E2BDF525E2BA1600F7A304 /* Config.swift */, 291 | ); 292 | path = Config; 293 | sourceTree = ""; 294 | }; 295 | F4E2BDF625E2BA1600F7A304 /* Network */ = { 296 | isa = PBXGroup; 297 | children = ( 298 | F4E2BDF725E2BA1600F7A304 /* Mock */, 299 | F4E2BDF925E2BA1600F7A304 /* RestAPI.swift */, 300 | F4E2BDFA25E2BA1600F7A304 /* ArticleService.swift */, 301 | ); 302 | path = Network; 303 | sourceTree = ""; 304 | }; 305 | F4E2BDF725E2BA1600F7A304 /* Mock */ = { 306 | isa = PBXGroup; 307 | children = ( 308 | F4E2BDF825E2BA1600F7A304 /* searchArticles.json */, 309 | ); 310 | path = Mock; 311 | sourceTree = ""; 312 | }; 313 | F4E2BDFB25E2BA1600F7A304 /* Repositories */ = { 314 | isa = PBXGroup; 315 | children = ( 316 | F4E2BDFC25E2BA1600F7A304 /* ArticleRepository.swift */, 317 | ); 318 | path = Repositories; 319 | sourceTree = ""; 320 | }; 321 | F4E2BDFD25E2BA1600F7A304 /* Domain */ = { 322 | isa = PBXGroup; 323 | children = ( 324 | F4E2BE0025E2BA1600F7A304 /* Entities */, 325 | F4E2BDFE25E2BA1600F7A304 /* UseCases */, 326 | ); 327 | path = Domain; 328 | sourceTree = ""; 329 | }; 330 | F4E2BDFE25E2BA1600F7A304 /* UseCases */ = { 331 | isa = PBXGroup; 332 | children = ( 333 | F4E2BDFF25E2BA1600F7A304 /* ArticleUseCase.swift */, 334 | ); 335 | path = UseCases; 336 | sourceTree = ""; 337 | }; 338 | F4E2BE0025E2BA1600F7A304 /* Entities */ = { 339 | isa = PBXGroup; 340 | children = ( 341 | 1463DD2525E38CAB004BEE4F /* CodableDefault.swift */, 342 | F4E2BE0125E2BA1600F7A304 /* Article.swift */, 343 | ); 344 | path = Entities; 345 | sourceTree = ""; 346 | }; 347 | F4E2BE7725E2C4CD00F7A304 /* ViewModelTests */ = { 348 | isa = PBXGroup; 349 | children = ( 350 | F4E2BE7825E2C4CD00F7A304 /* ArticleListViewModelTests.swift */, 351 | ); 352 | path = ViewModelTests; 353 | sourceTree = ""; 354 | }; 355 | F4E2BE7925E2C4CD00F7A304 /* Helpers */ = { 356 | isa = PBXGroup; 357 | children = ( 358 | F4E2BE7A25E2C4CD00F7A304 /* MockLoader.swift */, 359 | ); 360 | path = Helpers; 361 | sourceTree = ""; 362 | }; 363 | F4E2BE7B25E2C4CD00F7A304 /* RepositoryMocks */ = { 364 | isa = PBXGroup; 365 | children = ( 366 | F4E2BE7C25E2C4CD00F7A304 /* Mock */, 367 | F4E2BE7E25E2C4CD00F7A304 /* ArticleRepositoryMock.swift */, 368 | ); 369 | path = RepositoryMocks; 370 | sourceTree = ""; 371 | }; 372 | F4E2BE7C25E2C4CD00F7A304 /* Mock */ = { 373 | isa = PBXGroup; 374 | children = ( 375 | F4E2BE7D25E2C4CD00F7A304 /* searchArticles.json */, 376 | ); 377 | path = Mock; 378 | sourceTree = ""; 379 | }; 380 | F4ECBE8D25E2A0720054DD81 = { 381 | isa = PBXGroup; 382 | children = ( 383 | F4ECBE9825E2A0720054DD81 /* CleanArchitecture */, 384 | F4ECBEAF25E2A0730054DD81 /* CleanArchitectureTests */, 385 | F4ECBEBA25E2A0730054DD81 /* CleanArchitectureUITests */, 386 | F4ECBE9725E2A0720054DD81 /* Products */, 387 | 1463DD0C25E36E3C004BEE4F /* Frameworks */, 388 | ); 389 | sourceTree = ""; 390 | }; 391 | F4ECBE9725E2A0720054DD81 /* Products */ = { 392 | isa = PBXGroup; 393 | children = ( 394 | F4ECBE9625E2A0720054DD81 /* CleanArchitecture.app */, 395 | F4ECBEAC25E2A0730054DD81 /* CleanArchitectureTests.xctest */, 396 | F4ECBEB725E2A0730054DD81 /* CleanArchitectureUITests.xctest */, 397 | ); 398 | name = Products; 399 | sourceTree = ""; 400 | }; 401 | F4ECBE9825E2A0720054DD81 /* CleanArchitecture */ = { 402 | isa = PBXGroup; 403 | children = ( 404 | F4E2BDFD25E2BA1600F7A304 /* Domain */, 405 | F4E2BDF325E2BA1600F7A304 /* Data */, 406 | F4E2BDD425E2BA1600F7A304 /* App */, 407 | F4ECBE9925E2A0720054DD81 /* AppDelegate.swift */, 408 | F4ECBE9F25E2A0720054DD81 /* Main.storyboard */, 409 | F4ECBEA225E2A0730054DD81 /* Assets.xcassets */, 410 | F4ECBEA425E2A0730054DD81 /* LaunchScreen.storyboard */, 411 | F4ECBEA725E2A0730054DD81 /* Info.plist */, 412 | ); 413 | path = CleanArchitecture; 414 | sourceTree = ""; 415 | }; 416 | F4ECBEAF25E2A0730054DD81 /* CleanArchitectureTests */ = { 417 | isa = PBXGroup; 418 | children = ( 419 | F4E2BE7925E2C4CD00F7A304 /* Helpers */, 420 | F4E2BE7B25E2C4CD00F7A304 /* RepositoryMocks */, 421 | F4E2BE7725E2C4CD00F7A304 /* ViewModelTests */, 422 | F4ECBEB225E2A0730054DD81 /* Info.plist */, 423 | ); 424 | path = CleanArchitectureTests; 425 | sourceTree = ""; 426 | }; 427 | F4ECBEBA25E2A0730054DD81 /* CleanArchitectureUITests */ = { 428 | isa = PBXGroup; 429 | children = ( 430 | F4ECBEBB25E2A0730054DD81 /* CleanArchitectureUITests.swift */, 431 | F4ECBEBD25E2A0730054DD81 /* Info.plist */, 432 | ); 433 | path = CleanArchitectureUITests; 434 | sourceTree = ""; 435 | }; 436 | /* End PBXGroup section */ 437 | 438 | /* Begin PBXNativeTarget section */ 439 | F4ECBE9525E2A0720054DD81 /* CleanArchitecture */ = { 440 | isa = PBXNativeTarget; 441 | buildConfigurationList = F4ECBEC025E2A0730054DD81 /* Build configuration list for PBXNativeTarget "CleanArchitecture" */; 442 | buildPhases = ( 443 | F4ECBE9225E2A0720054DD81 /* Sources */, 444 | F4ECBE9325E2A0720054DD81 /* Frameworks */, 445 | F4ECBE9425E2A0720054DD81 /* Resources */, 446 | ); 447 | buildRules = ( 448 | ); 449 | dependencies = ( 450 | ); 451 | name = CleanArchitecture; 452 | packageProductDependencies = ( 453 | F4ECBED025E2B61F0054DD81 /* RxSwift */, 454 | F4ECBED425E2B61F0054DD81 /* RxRelay */, 455 | F4ECBED625E2B61F0054DD81 /* RxCocoa */, 456 | F4E2BDC625E2B7B600F7A304 /* Alamofire */, 457 | F4E2BDCC25E2B9A100F7A304 /* Kingfisher */, 458 | F4E2BE2325E2BAA600F7A304 /* Differentiator */, 459 | F4E2BE2525E2BAA600F7A304 /* RxDataSources */, 460 | 1463DD1B25E36E82004BEE4F /* RxTest */, 461 | 1463DD2025E36E96004BEE4F /* RxBlocking */, 462 | ); 463 | productName = CleanArchitecture; 464 | productReference = F4ECBE9625E2A0720054DD81 /* CleanArchitecture.app */; 465 | productType = "com.apple.product-type.application"; 466 | }; 467 | F4ECBEAB25E2A0730054DD81 /* CleanArchitectureTests */ = { 468 | isa = PBXNativeTarget; 469 | buildConfigurationList = F4ECBEC325E2A0730054DD81 /* Build configuration list for PBXNativeTarget "CleanArchitectureTests" */; 470 | buildPhases = ( 471 | F4ECBEA825E2A0730054DD81 /* Sources */, 472 | F4ECBEA925E2A0730054DD81 /* Frameworks */, 473 | F4ECBEAA25E2A0730054DD81 /* Resources */, 474 | ); 475 | buildRules = ( 476 | ); 477 | dependencies = ( 478 | F4ECBEAE25E2A0730054DD81 /* PBXTargetDependency */, 479 | ); 480 | name = CleanArchitectureTests; 481 | packageProductDependencies = ( 482 | ); 483 | productName = CleanArchitectureTests; 484 | productReference = F4ECBEAC25E2A0730054DD81 /* CleanArchitectureTests.xctest */; 485 | productType = "com.apple.product-type.bundle.unit-test"; 486 | }; 487 | F4ECBEB625E2A0730054DD81 /* CleanArchitectureUITests */ = { 488 | isa = PBXNativeTarget; 489 | buildConfigurationList = F4ECBEC625E2A0730054DD81 /* Build configuration list for PBXNativeTarget "CleanArchitectureUITests" */; 490 | buildPhases = ( 491 | F4ECBEB325E2A0730054DD81 /* Sources */, 492 | F4ECBEB425E2A0730054DD81 /* Frameworks */, 493 | F4ECBEB525E2A0730054DD81 /* Resources */, 494 | ); 495 | buildRules = ( 496 | ); 497 | dependencies = ( 498 | F4ECBEB925E2A0730054DD81 /* PBXTargetDependency */, 499 | ); 500 | name = CleanArchitectureUITests; 501 | productName = CleanArchitectureUITests; 502 | productReference = F4ECBEB725E2A0730054DD81 /* CleanArchitectureUITests.xctest */; 503 | productType = "com.apple.product-type.bundle.ui-testing"; 504 | }; 505 | /* End PBXNativeTarget section */ 506 | 507 | /* Begin PBXProject section */ 508 | F4ECBE8E25E2A0720054DD81 /* Project object */ = { 509 | isa = PBXProject; 510 | attributes = { 511 | LastSwiftUpdateCheck = 1210; 512 | LastUpgradeCheck = 1210; 513 | TargetAttributes = { 514 | F4ECBE9525E2A0720054DD81 = { 515 | CreatedOnToolsVersion = 12.1; 516 | }; 517 | F4ECBEAB25E2A0730054DD81 = { 518 | CreatedOnToolsVersion = 12.1; 519 | TestTargetID = F4ECBE9525E2A0720054DD81; 520 | }; 521 | F4ECBEB625E2A0730054DD81 = { 522 | CreatedOnToolsVersion = 12.1; 523 | TestTargetID = F4ECBE9525E2A0720054DD81; 524 | }; 525 | }; 526 | }; 527 | buildConfigurationList = F4ECBE9125E2A0720054DD81 /* Build configuration list for PBXProject "CleanArchitecture" */; 528 | compatibilityVersion = "Xcode 9.3"; 529 | developmentRegion = en; 530 | hasScannedForEncodings = 0; 531 | knownRegions = ( 532 | en, 533 | Base, 534 | ); 535 | mainGroup = F4ECBE8D25E2A0720054DD81; 536 | packageReferences = ( 537 | F4ECBECF25E2B61F0054DD81 /* XCRemoteSwiftPackageReference "RxSwift" */, 538 | F4E2BDC525E2B7B600F7A304 /* XCRemoteSwiftPackageReference "Alamofire" */, 539 | F4E2BDCB25E2B9A100F7A304 /* XCRemoteSwiftPackageReference "Kingfisher" */, 540 | F4E2BE2225E2BAA600F7A304 /* XCRemoteSwiftPackageReference "RxDataSources" */, 541 | ); 542 | productRefGroup = F4ECBE9725E2A0720054DD81 /* Products */; 543 | projectDirPath = ""; 544 | projectRoot = ""; 545 | targets = ( 546 | F4ECBE9525E2A0720054DD81 /* CleanArchitecture */, 547 | F4ECBEAB25E2A0730054DD81 /* CleanArchitectureTests */, 548 | F4ECBEB625E2A0730054DD81 /* CleanArchitectureUITests */, 549 | ); 550 | }; 551 | /* End PBXProject section */ 552 | 553 | /* Begin PBXResourcesBuildPhase section */ 554 | F4ECBE9425E2A0720054DD81 /* Resources */ = { 555 | isa = PBXResourcesBuildPhase; 556 | buildActionMask = 2147483647; 557 | files = ( 558 | 147E75F825E77957001C9FE6 /* a.html in Resources */, 559 | F4E2BE1625E2BA1600F7A304 /* searchArticles.json in Resources */, 560 | F4ECBEA625E2A0730054DD81 /* LaunchScreen.storyboard in Resources */, 561 | F4E2BE0725E2BA1600F7A304 /* Article.storyboard in Resources */, 562 | F4ECBEA325E2A0730054DD81 /* Assets.xcassets in Resources */, 563 | F4ECBEA125E2A0720054DD81 /* Main.storyboard in Resources */, 564 | ); 565 | runOnlyForDeploymentPostprocessing = 0; 566 | }; 567 | F4ECBEAA25E2A0730054DD81 /* Resources */ = { 568 | isa = PBXResourcesBuildPhase; 569 | buildActionMask = 2147483647; 570 | files = ( 571 | F4E2BE8125E2C4CD00F7A304 /* searchArticles.json in Resources */, 572 | ); 573 | runOnlyForDeploymentPostprocessing = 0; 574 | }; 575 | F4ECBEB525E2A0730054DD81 /* Resources */ = { 576 | isa = PBXResourcesBuildPhase; 577 | buildActionMask = 2147483647; 578 | files = ( 579 | ); 580 | runOnlyForDeploymentPostprocessing = 0; 581 | }; 582 | /* End PBXResourcesBuildPhase section */ 583 | 584 | /* Begin PBXSourcesBuildPhase section */ 585 | F4ECBE9225E2A0720054DD81 /* Sources */ = { 586 | isa = PBXSourcesBuildPhase; 587 | buildActionMask = 2147483647; 588 | files = ( 589 | F4E2BE1B25E2BA1600F7A304 /* Article.swift in Sources */, 590 | F4E2BE0925E2BA1600F7A304 /* UITableView+Extensions.swift in Sources */, 591 | F4E2BE1825E2BA1600F7A304 /* ArticleService.swift in Sources */, 592 | F4E2BE1025E2BA1600F7A304 /* Toast.swift in Sources */, 593 | F4E2BE0A25E2BA1600F7A304 /* UITableView+Rx.swift in Sources */, 594 | 1463DD2625E38CAB004BEE4F /* CodableDefault.swift in Sources */, 595 | F4E2BE0825E2BA1600F7A304 /* ArticleNavigator.swift in Sources */, 596 | F4E2BE0C25E2BA1600F7A304 /* Resolver.swift in Sources */, 597 | F4E2BE1225E2BA1600F7A304 /* ActivityIndicator.swift in Sources */, 598 | F4E2BE0D25E2BA1600F7A304 /* Storyboard.swift in Sources */, 599 | F4E2BE1125E2BA1600F7A304 /* ErrorTracker.swift in Sources */, 600 | F4E2BE1725E2BA1600F7A304 /* RestAPI.swift in Sources */, 601 | F4E2BE0225E2BA1600F7A304 /* ArticleListViewController.swift in Sources */, 602 | F4E2BE0E25E2BA1600F7A304 /* Alert.swift in Sources */, 603 | F4E2BE0F25E2BA1600F7A304 /* Loading.swift in Sources */, 604 | F4E2BE0325E2BA1600F7A304 /* ArticleListViewModel.swift in Sources */, 605 | F4E2BE0525E2BA1600F7A304 /* ArticleDetailViewModel.swift in Sources */, 606 | F4E2BE1A25E2BA1600F7A304 /* ArticleUseCase.swift in Sources */, 607 | F4E2BE1425E2BA1600F7A304 /* Color.swift in Sources */, 608 | F4E2BE0625E2BA1600F7A304 /* ArticleDetailViewController.swift in Sources */, 609 | F4E2BE0B25E2BA1600F7A304 /* ViewState+Rx.swift in Sources */, 610 | F4E2BE1525E2BA1600F7A304 /* Config.swift in Sources */, 611 | F4E2BE1325E2BA1600F7A304 /* ViewModelType.swift in Sources */, 612 | F4E2BE0425E2BA1600F7A304 /* ArticleCell.swift in Sources */, 613 | F4E2BE1925E2BA1600F7A304 /* ArticleRepository.swift in Sources */, 614 | F4ECBE9A25E2A0720054DD81 /* AppDelegate.swift in Sources */, 615 | ); 616 | runOnlyForDeploymentPostprocessing = 0; 617 | }; 618 | F4ECBEA825E2A0730054DD81 /* Sources */ = { 619 | isa = PBXSourcesBuildPhase; 620 | buildActionMask = 2147483647; 621 | files = ( 622 | F4E2BE8225E2C4CD00F7A304 /* ArticleRepositoryMock.swift in Sources */, 623 | F4E2BE7F25E2C4CD00F7A304 /* ArticleListViewModelTests.swift in Sources */, 624 | F4E2BE8025E2C4CD00F7A304 /* MockLoader.swift in Sources */, 625 | ); 626 | runOnlyForDeploymentPostprocessing = 0; 627 | }; 628 | F4ECBEB325E2A0730054DD81 /* Sources */ = { 629 | isa = PBXSourcesBuildPhase; 630 | buildActionMask = 2147483647; 631 | files = ( 632 | F4ECBEBC25E2A0730054DD81 /* CleanArchitectureUITests.swift in Sources */, 633 | ); 634 | runOnlyForDeploymentPostprocessing = 0; 635 | }; 636 | /* End PBXSourcesBuildPhase section */ 637 | 638 | /* Begin PBXTargetDependency section */ 639 | F4ECBEAE25E2A0730054DD81 /* PBXTargetDependency */ = { 640 | isa = PBXTargetDependency; 641 | target = F4ECBE9525E2A0720054DD81 /* CleanArchitecture */; 642 | targetProxy = F4ECBEAD25E2A0730054DD81 /* PBXContainerItemProxy */; 643 | }; 644 | F4ECBEB925E2A0730054DD81 /* PBXTargetDependency */ = { 645 | isa = PBXTargetDependency; 646 | target = F4ECBE9525E2A0720054DD81 /* CleanArchitecture */; 647 | targetProxy = F4ECBEB825E2A0730054DD81 /* PBXContainerItemProxy */; 648 | }; 649 | /* End PBXTargetDependency section */ 650 | 651 | /* Begin PBXVariantGroup section */ 652 | F4ECBE9F25E2A0720054DD81 /* Main.storyboard */ = { 653 | isa = PBXVariantGroup; 654 | children = ( 655 | F4ECBEA025E2A0720054DD81 /* Base */, 656 | ); 657 | name = Main.storyboard; 658 | sourceTree = ""; 659 | }; 660 | F4ECBEA425E2A0730054DD81 /* LaunchScreen.storyboard */ = { 661 | isa = PBXVariantGroup; 662 | children = ( 663 | F4ECBEA525E2A0730054DD81 /* Base */, 664 | ); 665 | name = LaunchScreen.storyboard; 666 | sourceTree = ""; 667 | }; 668 | /* End PBXVariantGroup section */ 669 | 670 | /* Begin XCBuildConfiguration section */ 671 | F4ECBEBE25E2A0730054DD81 /* Debug */ = { 672 | isa = XCBuildConfiguration; 673 | buildSettings = { 674 | ALWAYS_SEARCH_USER_PATHS = NO; 675 | CLANG_ANALYZER_NONNULL = YES; 676 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 677 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 678 | CLANG_CXX_LIBRARY = "libc++"; 679 | CLANG_ENABLE_MODULES = YES; 680 | CLANG_ENABLE_OBJC_ARC = YES; 681 | CLANG_ENABLE_OBJC_WEAK = YES; 682 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 683 | CLANG_WARN_BOOL_CONVERSION = YES; 684 | CLANG_WARN_COMMA = YES; 685 | CLANG_WARN_CONSTANT_CONVERSION = YES; 686 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 687 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 688 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 689 | CLANG_WARN_EMPTY_BODY = YES; 690 | CLANG_WARN_ENUM_CONVERSION = YES; 691 | CLANG_WARN_INFINITE_RECURSION = YES; 692 | CLANG_WARN_INT_CONVERSION = YES; 693 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 694 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 695 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 696 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 697 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 698 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 699 | CLANG_WARN_STRICT_PROTOTYPES = YES; 700 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 701 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 702 | CLANG_WARN_UNREACHABLE_CODE = YES; 703 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 704 | COPY_PHASE_STRIP = NO; 705 | DEBUG_INFORMATION_FORMAT = dwarf; 706 | ENABLE_STRICT_OBJC_MSGSEND = YES; 707 | ENABLE_TESTABILITY = YES; 708 | GCC_C_LANGUAGE_STANDARD = gnu11; 709 | GCC_DYNAMIC_NO_PIC = NO; 710 | GCC_NO_COMMON_BLOCKS = YES; 711 | GCC_OPTIMIZATION_LEVEL = 0; 712 | GCC_PREPROCESSOR_DEFINITIONS = ( 713 | "DEBUG=1", 714 | "$(inherited)", 715 | ); 716 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 717 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 718 | GCC_WARN_UNDECLARED_SELECTOR = YES; 719 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 720 | GCC_WARN_UNUSED_FUNCTION = YES; 721 | GCC_WARN_UNUSED_VARIABLE = YES; 722 | IPHONEOS_DEPLOYMENT_TARGET = 14.1; 723 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 724 | MTL_FAST_MATH = YES; 725 | ONLY_ACTIVE_ARCH = YES; 726 | SDKROOT = iphoneos; 727 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 728 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 729 | }; 730 | name = Debug; 731 | }; 732 | F4ECBEBF25E2A0730054DD81 /* Release */ = { 733 | isa = XCBuildConfiguration; 734 | buildSettings = { 735 | ALWAYS_SEARCH_USER_PATHS = NO; 736 | CLANG_ANALYZER_NONNULL = YES; 737 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 738 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 739 | CLANG_CXX_LIBRARY = "libc++"; 740 | CLANG_ENABLE_MODULES = YES; 741 | CLANG_ENABLE_OBJC_ARC = YES; 742 | CLANG_ENABLE_OBJC_WEAK = YES; 743 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 744 | CLANG_WARN_BOOL_CONVERSION = YES; 745 | CLANG_WARN_COMMA = YES; 746 | CLANG_WARN_CONSTANT_CONVERSION = YES; 747 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 748 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 749 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 750 | CLANG_WARN_EMPTY_BODY = YES; 751 | CLANG_WARN_ENUM_CONVERSION = YES; 752 | CLANG_WARN_INFINITE_RECURSION = YES; 753 | CLANG_WARN_INT_CONVERSION = YES; 754 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 755 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 756 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 757 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 758 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 759 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 760 | CLANG_WARN_STRICT_PROTOTYPES = YES; 761 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 762 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 763 | CLANG_WARN_UNREACHABLE_CODE = YES; 764 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 765 | COPY_PHASE_STRIP = NO; 766 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 767 | ENABLE_NS_ASSERTIONS = NO; 768 | ENABLE_STRICT_OBJC_MSGSEND = YES; 769 | GCC_C_LANGUAGE_STANDARD = gnu11; 770 | GCC_NO_COMMON_BLOCKS = YES; 771 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 772 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 773 | GCC_WARN_UNDECLARED_SELECTOR = YES; 774 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 775 | GCC_WARN_UNUSED_FUNCTION = YES; 776 | GCC_WARN_UNUSED_VARIABLE = YES; 777 | IPHONEOS_DEPLOYMENT_TARGET = 14.1; 778 | MTL_ENABLE_DEBUG_INFO = NO; 779 | MTL_FAST_MATH = YES; 780 | SDKROOT = iphoneos; 781 | SWIFT_COMPILATION_MODE = wholemodule; 782 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 783 | VALIDATE_PRODUCT = YES; 784 | }; 785 | name = Release; 786 | }; 787 | F4ECBEC125E2A0730054DD81 /* Debug */ = { 788 | isa = XCBuildConfiguration; 789 | buildSettings = { 790 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 791 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 792 | CODE_SIGN_STYLE = Automatic; 793 | DEVELOPMENT_TEAM = C25B7N258R; 794 | ENABLE_TESTING_SEARCH_PATHS = YES; 795 | INFOPLIST_FILE = CleanArchitecture/Info.plist; 796 | IPHONEOS_DEPLOYMENT_TARGET = 10.0; 797 | LD_RUNPATH_SEARCH_PATHS = ( 798 | "$(inherited)", 799 | "@executable_path/Frameworks", 800 | ); 801 | PRODUCT_BUNDLE_IDENTIFIER = com.dinhquan.CleanArchitecture; 802 | PRODUCT_NAME = "$(TARGET_NAME)"; 803 | SWIFT_VERSION = 5.0; 804 | TARGETED_DEVICE_FAMILY = "1,2"; 805 | }; 806 | name = Debug; 807 | }; 808 | F4ECBEC225E2A0730054DD81 /* Release */ = { 809 | isa = XCBuildConfiguration; 810 | buildSettings = { 811 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 812 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 813 | CODE_SIGN_STYLE = Automatic; 814 | DEVELOPMENT_TEAM = C25B7N258R; 815 | ENABLE_TESTING_SEARCH_PATHS = YES; 816 | INFOPLIST_FILE = CleanArchitecture/Info.plist; 817 | IPHONEOS_DEPLOYMENT_TARGET = 10.0; 818 | LD_RUNPATH_SEARCH_PATHS = ( 819 | "$(inherited)", 820 | "@executable_path/Frameworks", 821 | ); 822 | PRODUCT_BUNDLE_IDENTIFIER = com.dinhquan.CleanArchitecture; 823 | PRODUCT_NAME = "$(TARGET_NAME)"; 824 | SWIFT_VERSION = 5.0; 825 | TARGETED_DEVICE_FAMILY = "1,2"; 826 | }; 827 | name = Release; 828 | }; 829 | F4ECBEC425E2A0730054DD81 /* Debug */ = { 830 | isa = XCBuildConfiguration; 831 | buildSettings = { 832 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 833 | BUNDLE_LOADER = "$(TEST_HOST)"; 834 | CODE_SIGN_STYLE = Automatic; 835 | INFOPLIST_FILE = CleanArchitectureTests/Info.plist; 836 | IPHONEOS_DEPLOYMENT_TARGET = 14.1; 837 | LD_RUNPATH_SEARCH_PATHS = ( 838 | "$(inherited)", 839 | "@executable_path/Frameworks", 840 | "@loader_path/Frameworks", 841 | ); 842 | PRODUCT_BUNDLE_IDENTIFIER = com.dinhquan.CleanArchitectureTests; 843 | PRODUCT_NAME = "$(TARGET_NAME)"; 844 | SWIFT_VERSION = 5.0; 845 | TARGETED_DEVICE_FAMILY = "1,2"; 846 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CleanArchitecture.app/CleanArchitecture"; 847 | }; 848 | name = Debug; 849 | }; 850 | F4ECBEC525E2A0730054DD81 /* Release */ = { 851 | isa = XCBuildConfiguration; 852 | buildSettings = { 853 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 854 | BUNDLE_LOADER = "$(TEST_HOST)"; 855 | CODE_SIGN_STYLE = Automatic; 856 | INFOPLIST_FILE = CleanArchitectureTests/Info.plist; 857 | IPHONEOS_DEPLOYMENT_TARGET = 14.1; 858 | LD_RUNPATH_SEARCH_PATHS = ( 859 | "$(inherited)", 860 | "@executable_path/Frameworks", 861 | "@loader_path/Frameworks", 862 | ); 863 | PRODUCT_BUNDLE_IDENTIFIER = com.dinhquan.CleanArchitectureTests; 864 | PRODUCT_NAME = "$(TARGET_NAME)"; 865 | SWIFT_VERSION = 5.0; 866 | TARGETED_DEVICE_FAMILY = "1,2"; 867 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CleanArchitecture.app/CleanArchitecture"; 868 | }; 869 | name = Release; 870 | }; 871 | F4ECBEC725E2A0730054DD81 /* Debug */ = { 872 | isa = XCBuildConfiguration; 873 | buildSettings = { 874 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 875 | CODE_SIGN_STYLE = Automatic; 876 | INFOPLIST_FILE = CleanArchitectureUITests/Info.plist; 877 | LD_RUNPATH_SEARCH_PATHS = ( 878 | "$(inherited)", 879 | "@executable_path/Frameworks", 880 | "@loader_path/Frameworks", 881 | ); 882 | PRODUCT_BUNDLE_IDENTIFIER = com.dinhquan.CleanArchitectureUITests; 883 | PRODUCT_NAME = "$(TARGET_NAME)"; 884 | SWIFT_VERSION = 5.0; 885 | TARGETED_DEVICE_FAMILY = "1,2"; 886 | TEST_TARGET_NAME = CleanArchitecture; 887 | }; 888 | name = Debug; 889 | }; 890 | F4ECBEC825E2A0730054DD81 /* Release */ = { 891 | isa = XCBuildConfiguration; 892 | buildSettings = { 893 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 894 | CODE_SIGN_STYLE = Automatic; 895 | INFOPLIST_FILE = CleanArchitectureUITests/Info.plist; 896 | LD_RUNPATH_SEARCH_PATHS = ( 897 | "$(inherited)", 898 | "@executable_path/Frameworks", 899 | "@loader_path/Frameworks", 900 | ); 901 | PRODUCT_BUNDLE_IDENTIFIER = com.dinhquan.CleanArchitectureUITests; 902 | PRODUCT_NAME = "$(TARGET_NAME)"; 903 | SWIFT_VERSION = 5.0; 904 | TARGETED_DEVICE_FAMILY = "1,2"; 905 | TEST_TARGET_NAME = CleanArchitecture; 906 | }; 907 | name = Release; 908 | }; 909 | /* End XCBuildConfiguration section */ 910 | 911 | /* Begin XCConfigurationList section */ 912 | F4ECBE9125E2A0720054DD81 /* Build configuration list for PBXProject "CleanArchitecture" */ = { 913 | isa = XCConfigurationList; 914 | buildConfigurations = ( 915 | F4ECBEBE25E2A0730054DD81 /* Debug */, 916 | F4ECBEBF25E2A0730054DD81 /* Release */, 917 | ); 918 | defaultConfigurationIsVisible = 0; 919 | defaultConfigurationName = Release; 920 | }; 921 | F4ECBEC025E2A0730054DD81 /* Build configuration list for PBXNativeTarget "CleanArchitecture" */ = { 922 | isa = XCConfigurationList; 923 | buildConfigurations = ( 924 | F4ECBEC125E2A0730054DD81 /* Debug */, 925 | F4ECBEC225E2A0730054DD81 /* Release */, 926 | ); 927 | defaultConfigurationIsVisible = 0; 928 | defaultConfigurationName = Release; 929 | }; 930 | F4ECBEC325E2A0730054DD81 /* Build configuration list for PBXNativeTarget "CleanArchitectureTests" */ = { 931 | isa = XCConfigurationList; 932 | buildConfigurations = ( 933 | F4ECBEC425E2A0730054DD81 /* Debug */, 934 | F4ECBEC525E2A0730054DD81 /* Release */, 935 | ); 936 | defaultConfigurationIsVisible = 0; 937 | defaultConfigurationName = Release; 938 | }; 939 | F4ECBEC625E2A0730054DD81 /* Build configuration list for PBXNativeTarget "CleanArchitectureUITests" */ = { 940 | isa = XCConfigurationList; 941 | buildConfigurations = ( 942 | F4ECBEC725E2A0730054DD81 /* Debug */, 943 | F4ECBEC825E2A0730054DD81 /* Release */, 944 | ); 945 | defaultConfigurationIsVisible = 0; 946 | defaultConfigurationName = Release; 947 | }; 948 | /* End XCConfigurationList section */ 949 | 950 | /* Begin XCRemoteSwiftPackageReference section */ 951 | F4E2BDC525E2B7B600F7A304 /* XCRemoteSwiftPackageReference "Alamofire" */ = { 952 | isa = XCRemoteSwiftPackageReference; 953 | repositoryURL = "https://github.com/Alamofire/Alamofire"; 954 | requirement = { 955 | kind = upToNextMajorVersion; 956 | minimumVersion = 5.4.1; 957 | }; 958 | }; 959 | F4E2BDCB25E2B9A100F7A304 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { 960 | isa = XCRemoteSwiftPackageReference; 961 | repositoryURL = "https://github.com/onevcat/Kingfisher"; 962 | requirement = { 963 | kind = upToNextMajorVersion; 964 | minimumVersion = 6.1.1; 965 | }; 966 | }; 967 | F4E2BE2225E2BAA600F7A304 /* XCRemoteSwiftPackageReference "RxDataSources" */ = { 968 | isa = XCRemoteSwiftPackageReference; 969 | repositoryURL = "https://github.com/RxSwiftCommunity/RxDataSources"; 970 | requirement = { 971 | kind = upToNextMajorVersion; 972 | minimumVersion = 5.0.0; 973 | }; 974 | }; 975 | F4ECBECF25E2B61F0054DD81 /* XCRemoteSwiftPackageReference "RxSwift" */ = { 976 | isa = XCRemoteSwiftPackageReference; 977 | repositoryURL = "https://github.com/ReactiveX/RxSwift"; 978 | requirement = { 979 | kind = upToNextMajorVersion; 980 | minimumVersion = 6.1.0; 981 | }; 982 | }; 983 | /* End XCRemoteSwiftPackageReference section */ 984 | 985 | /* Begin XCSwiftPackageProductDependency section */ 986 | 1463DD1B25E36E82004BEE4F /* RxTest */ = { 987 | isa = XCSwiftPackageProductDependency; 988 | package = F4ECBECF25E2B61F0054DD81 /* XCRemoteSwiftPackageReference "RxSwift" */; 989 | productName = RxTest; 990 | }; 991 | 1463DD2025E36E96004BEE4F /* RxBlocking */ = { 992 | isa = XCSwiftPackageProductDependency; 993 | package = F4ECBECF25E2B61F0054DD81 /* XCRemoteSwiftPackageReference "RxSwift" */; 994 | productName = RxBlocking; 995 | }; 996 | F4E2BDC625E2B7B600F7A304 /* Alamofire */ = { 997 | isa = XCSwiftPackageProductDependency; 998 | package = F4E2BDC525E2B7B600F7A304 /* XCRemoteSwiftPackageReference "Alamofire" */; 999 | productName = Alamofire; 1000 | }; 1001 | F4E2BDCC25E2B9A100F7A304 /* Kingfisher */ = { 1002 | isa = XCSwiftPackageProductDependency; 1003 | package = F4E2BDCB25E2B9A100F7A304 /* XCRemoteSwiftPackageReference "Kingfisher" */; 1004 | productName = Kingfisher; 1005 | }; 1006 | F4E2BE2325E2BAA600F7A304 /* Differentiator */ = { 1007 | isa = XCSwiftPackageProductDependency; 1008 | package = F4E2BE2225E2BAA600F7A304 /* XCRemoteSwiftPackageReference "RxDataSources" */; 1009 | productName = Differentiator; 1010 | }; 1011 | F4E2BE2525E2BAA600F7A304 /* RxDataSources */ = { 1012 | isa = XCSwiftPackageProductDependency; 1013 | package = F4E2BE2225E2BAA600F7A304 /* XCRemoteSwiftPackageReference "RxDataSources" */; 1014 | productName = RxDataSources; 1015 | }; 1016 | F4ECBED025E2B61F0054DD81 /* RxSwift */ = { 1017 | isa = XCSwiftPackageProductDependency; 1018 | package = F4ECBECF25E2B61F0054DD81 /* XCRemoteSwiftPackageReference "RxSwift" */; 1019 | productName = RxSwift; 1020 | }; 1021 | F4ECBED425E2B61F0054DD81 /* RxRelay */ = { 1022 | isa = XCSwiftPackageProductDependency; 1023 | package = F4ECBECF25E2B61F0054DD81 /* XCRemoteSwiftPackageReference "RxSwift" */; 1024 | productName = RxRelay; 1025 | }; 1026 | F4ECBED625E2B61F0054DD81 /* RxCocoa */ = { 1027 | isa = XCSwiftPackageProductDependency; 1028 | package = F4ECBECF25E2B61F0054DD81 /* XCRemoteSwiftPackageReference "RxSwift" */; 1029 | productName = RxCocoa; 1030 | }; 1031 | /* End XCSwiftPackageProductDependency section */ 1032 | }; 1033 | rootObject = F4ECBE8E25E2A0720054DD81 /* Project object */; 1034 | } 1035 | -------------------------------------------------------------------------------- /CleanArchitecture.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CleanArchitecture.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CleanArchitecture.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Alamofire", 6 | "repositoryURL": "https://github.com/Alamofire/Alamofire", 7 | "state": { 8 | "branch": null, 9 | "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", 10 | "version": "5.4.1" 11 | } 12 | }, 13 | { 14 | "package": "Kingfisher", 15 | "repositoryURL": "https://github.com/onevcat/Kingfisher", 16 | "state": { 17 | "branch": null, 18 | "revision": "81dd1ce8401137637663046c7314e7c885bcc56d", 19 | "version": "6.1.1" 20 | } 21 | }, 22 | { 23 | "package": "RxDataSources", 24 | "repositoryURL": "https://github.com/RxSwiftCommunity/RxDataSources", 25 | "state": { 26 | "branch": null, 27 | "revision": "241c62e7b578b2346c8b60efdf31b4eb2eab2966", 28 | "version": "5.0.0" 29 | } 30 | }, 31 | { 32 | "package": "RxSwift", 33 | "repositoryURL": "https://github.com/ReactiveX/RxSwift", 34 | "state": { 35 | "branch": null, 36 | "revision": "7e01c05f25c025143073eaa3be3532f9375c614b", 37 | "version": "6.1.0" 38 | } 39 | } 40 | ] 41 | }, 42 | "version": 1 43 | } 44 | -------------------------------------------------------------------------------- /CleanArchitecture.xcodeproj/xcshareddata/xcschemes/CleanArchitecture.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /CleanArchitecture.xcodeproj/xcshareddata/xcschemes/CleanArchitectureTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Common/Base/ViewModelType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModelType.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/5/21. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ViewModelProtocol { 11 | associatedtype Input 12 | associatedtype Output 13 | 14 | func transform(input: Input) -> Output 15 | } 16 | 17 | protocol ReuseID { 18 | static var reuseId: String { get } 19 | } 20 | 21 | extension ReuseID { 22 | static var reuseId: String { 23 | String(describing: Self.self) 24 | } 25 | } 26 | 27 | protocol CellViewModelProtocol: AnyObject, ReuseID { 28 | associatedtype ViewModel 29 | 30 | func config(with viewModel: ViewModel) 31 | } 32 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Common/Resolver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Resolver.swift 3 | // 4 | // GitHub Repo and Documentation: https://github.com/hmlongco/Resolver 5 | // 6 | // Copyright © 2017 Michael Long. All rights reserved. 7 | // 8 | // Permission is hereby granted, free of charge, to any person obtaining a copy 9 | // of this software and associated documentation files (the "Software"), to deal 10 | // in the Software without restriction, including without limitation the rights 11 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | // copies of the Software, and to permit persons to whom the Software is 13 | // furnished to do so, subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be included in 16 | // all copies or substantial portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | // THE SOFTWARE. 25 | // 26 | 27 | // swiftformat:disable all 28 | 29 | #if os(iOS) 30 | import UIKit 31 | import SwiftUI 32 | #elseif os(macOS) || os(tvOS) || os(watchOS) 33 | import Foundation 34 | import SwiftUI 35 | #else 36 | import Foundation 37 | #endif 38 | 39 | // swiftlint:disable file_length 40 | 41 | public protocol ResolverRegistering { 42 | static func registerAllServices() 43 | } 44 | 45 | /// The Resolving protocol is used to make the Resolver registries available to a given class. 46 | public protocol Resolving { 47 | var resolver: Resolver { get } 48 | } 49 | 50 | extension Resolving { 51 | public var resolver: Resolver { 52 | return Resolver.root 53 | } 54 | } 55 | 56 | /// Resolver is a Dependency Injection registry that registers Services for later resolution and 57 | /// injection into newly constructed instances. 58 | public final class Resolver { 59 | 60 | // MARK: - Defaults 61 | 62 | /// Default registry used by the static Registration functions. 63 | public static var main: Resolver = Resolver() 64 | /// Default registry used by the static Resolution functions and by the Resolving protocol. 65 | public static var root: Resolver = main 66 | /// Default scope applied when registering new objects. 67 | public static var defaultScope: ResolverScope = Resolver.graph 68 | 69 | // MARK: - Lifecycle 70 | 71 | public init(parent: Resolver? = nil) { 72 | self.parent = parent 73 | } 74 | 75 | /// Called by the Resolution functions to perform one-time initialization of the Resolver registries. 76 | public final func registerServices() { 77 | Resolver.registerServices?() 78 | } 79 | 80 | /// Called by the Resolution functions to perform one-time initialization of the Resolver registries. 81 | public static var registerServices: (() -> Void)? = registerServicesBlock 82 | 83 | private static var registerServicesBlock: (() -> Void) = { () in 84 | pthread_mutex_lock(&Resolver.registrationMutex) 85 | defer { pthread_mutex_unlock(&Resolver.registrationMutex) } 86 | if Resolver.registerServices != nil, let registering = (Resolver.root as Any) as? ResolverRegistering { 87 | type(of: registering).registerAllServices() 88 | } 89 | Resolver.registerServices = nil 90 | } 91 | 92 | /// Called to effectively reset Resolver to its initial state, including recalling registerAllServices if it was provided 93 | public static func reset() { 94 | pthread_mutex_lock(&Resolver.registrationMutex) 95 | defer { pthread_mutex_unlock(&Resolver.registrationMutex) } 96 | main = Resolver() 97 | root = main 98 | registerServices = registerServicesBlock 99 | } 100 | 101 | // MARK: - Service Registration 102 | 103 | /// Static shortcut function used to register a specifc Service type and its instantiating factory method. 104 | /// 105 | /// - parameter type: Type of Service being registered. Optional, may be inferred by factory result type. 106 | /// - parameter name: Named variant of Service being registered. 107 | /// - parameter factory: Closure that constructs and returns instances of the Service. 108 | /// 109 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 110 | /// 111 | @discardableResult 112 | public static func register(_ type: Service.Type = Service.self, name: String? = nil, 113 | factory: @escaping ResolverFactory) -> ResolverOptions { 114 | return main.register(type, name: name, factory: { (_, _) -> Service? in return factory() }) 115 | } 116 | 117 | /// Static shortcut function used to register a specifc Service type and its instantiating factory method. 118 | /// 119 | /// - parameter type: Type of Service being registered. Optional, may be inferred by factory result type. 120 | /// - parameter name: Named variant of Service being registered. 121 | /// - parameter factory: Closure that constructs and returns instances of the Service. 122 | /// 123 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 124 | /// 125 | @discardableResult 126 | public static func register(_ type: Service.Type = Service.self, name: String? = nil, 127 | factory: @escaping ResolverFactoryResolver) -> ResolverOptions { 128 | return main.register(type, name: name, factory: { (r, _) -> Service? in return factory(r) }) 129 | } 130 | 131 | /// Static shortcut function used to register a specifc Service type and its instantiating factory method. 132 | /// 133 | /// - parameter type: Type of Service being registered. Optional, may be inferred by factory result type. 134 | /// - parameter name: Named variant of Service being registered. 135 | /// - parameter factory: Closure that accepts arguments and constructs and returns instances of the Service. 136 | /// 137 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 138 | /// 139 | @discardableResult 140 | public static func register(_ type: Service.Type = Service.self, name: String? = nil, 141 | factory: @escaping ResolverFactoryArguments) -> ResolverOptions { 142 | return main.register(type, name: name, factory: factory) 143 | } 144 | 145 | /// Registers a specifc Service type and its instantiating factory method. 146 | /// 147 | /// - parameter type: Type of Service being registered. Optional, may be inferred by factory result type. 148 | /// - parameter name: Named variant of Service being registered. 149 | /// - parameter factory: Closure that constructs and returns instances of the Service. 150 | /// 151 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 152 | /// 153 | @discardableResult 154 | public final func register(_ type: Service.Type = Service.self, name: String? = nil, 155 | factory: @escaping ResolverFactory) -> ResolverOptions { 156 | return register(type, name: name, factory: { (_, _) -> Service? in return factory() }) 157 | } 158 | 159 | /// Registers a specifc Service type and its instantiating factory method. 160 | /// 161 | /// - parameter type: Type of Service being registered. Optional, may be inferred by factory result type. 162 | /// - parameter name: Named variant of Service being registered. 163 | /// - parameter factory: Closure that constructs and returns instances of the Service. 164 | /// 165 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 166 | /// 167 | @discardableResult 168 | public final func register(_ type: Service.Type = Service.self, name: String? = nil, 169 | factory: @escaping ResolverFactoryResolver) -> ResolverOptions { 170 | return register(type, name: name, factory: { (r, _) -> Service? in return factory(r) }) 171 | } 172 | 173 | /// Registers a specifc Service type and its instantiating factory method. 174 | /// 175 | /// - parameter type: Type of Service being registered. Optional, may be inferred by factory result type. 176 | /// - parameter name: Named variant of Service being registered. 177 | /// - parameter factory: Closure that accepts arguments and constructs and returns instances of the Service. 178 | /// 179 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 180 | /// 181 | @discardableResult 182 | public final func register(_ type: Service.Type = Service.self, name: String? = nil, 183 | factory: @escaping ResolverFactoryArguments) -> ResolverOptions { 184 | let key = ObjectIdentifier(Service.self).hashValue 185 | let registration = ResolverRegistration(resolver: self, key: key, name: name, factory: factory) 186 | if var container = registrations[key] { 187 | container[name ?? NONAME] = registration 188 | registrations[key] = container 189 | } else { 190 | registrations[key] = [name ?? NONAME : registration] 191 | } 192 | return registration 193 | } 194 | 195 | // MARK: - Service Resolution 196 | 197 | /// Static function calls the root registry to resolve a given Service type. 198 | /// 199 | /// - parameter type: Type of Service being resolved. Optional, may be inferred by assignment result type. 200 | /// - parameter name: Named variant of Service being resolved. 201 | /// - parameter args: Optional arguments that may be passed to registration factory. 202 | /// 203 | /// - returns: Instance of specified Service. 204 | public static func resolve(_ type: Service.Type = Service.self, name: String? = nil, args: Any? = nil) -> Service { 205 | Resolver.registerServices?() // always check initial registrations first in case registerAllServices swaps root 206 | return root.resolve(type, name: name, args: args) 207 | } 208 | 209 | /// Resolves and returns an instance of the given Service type from the current registry or from its 210 | /// parent registries. 211 | /// 212 | /// - parameter type: Type of Service being resolved. Optional, may be inferred by assignment result type. 213 | /// - parameter name: Named variant of Service being resolved. 214 | /// - parameter args: Optional arguments that may be passed to registration factory. 215 | /// 216 | /// - returns: Instance of specified Service. 217 | /// 218 | public final func resolve(_ type: Service.Type = Service.self, name: String? = nil, args: Any? = nil) -> Service { 219 | if let registration = lookup(type, name: name ?? NONAME), 220 | let service = registration.scope.resolve(resolver: self, registration: registration, args: args) { 221 | return service 222 | } 223 | fatalError("RESOLVER: '\(Service.self):\(name ?? "")' not resolved. To disambiguate optionals use resover.optional().") 224 | } 225 | 226 | /// Static function calls the root registry to resolve an optional Service type. 227 | /// 228 | /// - parameter type: Type of Service being resolved. Optional, may be inferred by assignment result type. 229 | /// - parameter name: Named variant of Service being resolved. 230 | /// - parameter args: Optional arguments that may be passed to registration factory. 231 | /// 232 | /// - returns: Instance of specified Service. 233 | /// 234 | public static func optional(_ type: Service.Type = Service.self, name: String? = nil, args: Any? = nil) -> Service? { 235 | Resolver.registerServices?() // always check initial registrations first in case registerAllServices swaps root 236 | return root.optional(type, name: name, args: args) 237 | } 238 | 239 | /// Resolves and returns an optional instance of the given Service type from the current registry or 240 | /// from its parent registries. 241 | /// 242 | /// - parameter type: Type of Service being resolved. Optional, may be inferred by assignment result type. 243 | /// - parameter name: Named variant of Service being resolved. 244 | /// - parameter args: Optional arguments that may be passed to registration factory. 245 | /// 246 | /// - returns: Instance of specified Service. 247 | /// 248 | public final func optional(_ type: Service.Type = Service.self, name: String? = nil, args: Any? = nil) -> Service? { 249 | if let registration = lookup(type, name: name ?? NONAME), 250 | let service = registration.scope.resolve(resolver: self, registration: registration, args: args) { 251 | return service 252 | } 253 | return nil 254 | } 255 | 256 | // MARK: - Internal 257 | 258 | /// Internal function searches the current and parent registries for a ResolverRegistration that matches 259 | /// the supplied type and name. 260 | private final func lookup(_ type: Service.Type, name: String) -> ResolverRegistration? { 261 | Resolver.registerServices?() 262 | if let container = registrations[ObjectIdentifier(Service.self).hashValue] { 263 | return container[name] as? ResolverRegistration 264 | } 265 | if let parent = parent, let registration = parent.lookup(type, name: name) { 266 | return registration 267 | } 268 | return nil 269 | } 270 | 271 | private let NONAME = "*" 272 | private let parent: Resolver? 273 | private var registrations = [Int : [String : Any]]() 274 | private static var registrationMutex: pthread_mutex_t = { 275 | var mutex = pthread_mutex_t() 276 | pthread_mutex_init(&mutex, nil) 277 | return mutex 278 | }() 279 | } 280 | 281 | // Registration Internals 282 | 283 | public typealias ResolverFactory = () -> Service? 284 | public typealias ResolverFactoryResolver = (_ resolver: Resolver) -> Service? 285 | public typealias ResolverFactoryArguments = (_ resolver: Resolver, _ args: Any?) -> Service? 286 | public typealias ResolverFactoryMutator = (_ resolver: Resolver, _ service: Service) -> Void 287 | public typealias ResolverFactoryMutatorArguments = (_ resolver: Resolver, _ service: Service, _ args: Any?) -> Void 288 | 289 | /// A ResolverOptions instance is returned by a registration function in order to allow additonal configuratiom. (e.g. scopes, etc.) 290 | public class ResolverOptions { 291 | 292 | // MARK: - Parameters 293 | 294 | public var scope: ResolverScope 295 | 296 | fileprivate var factory: ResolverFactoryArguments 297 | fileprivate var mutator: ResolverFactoryMutatorArguments? 298 | fileprivate weak var resolver: Resolver? 299 | 300 | // MARK: - Lifecycle 301 | 302 | public init(resolver: Resolver, factory: @escaping ResolverFactoryArguments) { 303 | self.factory = factory 304 | self.resolver = resolver 305 | self.scope = Resolver.defaultScope 306 | } 307 | 308 | // MARK: - Fuctionality 309 | 310 | /// Indicates that the registered Service also implements a specific protocol that may be resolved on 311 | /// its own. 312 | /// 313 | /// - parameter type: Type of protocol being registered. 314 | /// - parameter name: Named variant of protocol being registered. 315 | /// 316 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 317 | /// 318 | @discardableResult 319 | public final func implements(_ type: Protocol.Type, name: String? = nil) -> ResolverOptions { 320 | resolver?.register(type.self, name: name) { r, _ in r.resolve(Service.self) as? Protocol } 321 | return self 322 | } 323 | 324 | /// Allows easy assignment of injected properties into resolved Service. 325 | /// 326 | /// - parameter block: Resolution block. 327 | /// 328 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 329 | /// 330 | @discardableResult 331 | public final func resolveProperties(_ block: @escaping ResolverFactoryMutator) -> ResolverOptions { 332 | mutator = { r, s, _ in block(r, s) } 333 | return self 334 | } 335 | 336 | /// Allows easy assignment of injected properties into resolved Service. 337 | /// 338 | /// - parameter block: Resolution block that also receives resolution arguments. 339 | /// 340 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 341 | /// 342 | @discardableResult 343 | public final func resolveProperties(_ block: @escaping ResolverFactoryMutatorArguments) -> ResolverOptions { 344 | mutator = block 345 | return self 346 | } 347 | 348 | /// Defines scope in which requested Service may be cached. 349 | /// 350 | /// - parameter block: Resolution block. 351 | /// 352 | /// - returns: ResolverOptions instance that allows further customization of registered Service. 353 | /// 354 | @discardableResult 355 | public final func scope(_ scope: ResolverScope) -> ResolverOptions { 356 | self.scope = scope 357 | return self 358 | } 359 | 360 | } 361 | 362 | /// ResolverRegistration stores a service definition and its factory closure. 363 | public final class ResolverRegistration: ResolverOptions { 364 | 365 | // MARK: Parameters 366 | 367 | public var key: Int 368 | public var cacheKey: String 369 | 370 | // MARK: Lifecycle 371 | 372 | public init(resolver: Resolver, key: Int, name: String?, factory: @escaping ResolverFactoryArguments) { 373 | self.key = key 374 | if let namedService = name { 375 | self.cacheKey = String(key) + ":" + namedService 376 | } else { 377 | self.cacheKey = String(key) 378 | } 379 | super.init(resolver: resolver, factory: factory) 380 | } 381 | 382 | // MARK: Functions 383 | 384 | public final func resolve(resolver: Resolver, args: Any?) -> Service? { 385 | guard let service = factory(resolver, args) else { 386 | return nil 387 | } 388 | self.mutator?(resolver, service, args) 389 | return service 390 | } 391 | } 392 | 393 | // Scopes 394 | 395 | extension Resolver { 396 | 397 | // MARK: - Scopes 398 | 399 | /// All application scoped services exist for lifetime of the app. (e.g Singletons) 400 | public static let application = ResolverScopeApplication() 401 | /// Cached services exist for lifetime of the app or until their cache is reset. 402 | public static let cached = ResolverScopeCache() 403 | /// Graph services are initialized once and only once during a given resolution cycle. This is the default scope. 404 | public static let graph = ResolverScopeGraph() 405 | /// Shared services persist while strong references to them exist. They're then deallocated until the next resolve. 406 | public static let shared = ResolverScopeShare() 407 | /// Unique services are created and initialized each and every time they're resolved. 408 | public static let unique = ResolverScopeUnique() 409 | 410 | } 411 | 412 | /// Resolver scopes exist to control when resolution occurs and how resolved instances are cached. (If at all.) 413 | public protocol ResolverScope: class { 414 | func resolve(resolver: Resolver, registration: ResolverRegistration, args: Any?) -> Service? 415 | } 416 | 417 | /// All application scoped services exist for lifetime of the app. (e.g Singletons) 418 | public class ResolverScopeApplication: ResolverScope { 419 | 420 | public init() { 421 | pthread_mutex_init(&mutex, nil) 422 | } 423 | 424 | public final func resolve(resolver: Resolver, registration: ResolverRegistration, args: Any?) -> Service? { 425 | pthread_mutex_lock(&mutex) 426 | let existingService = cachedServices[registration.cacheKey] as? Service 427 | pthread_mutex_unlock(&mutex) 428 | 429 | if let service = existingService { 430 | return service 431 | } 432 | 433 | let service = registration.resolve(resolver: resolver, args: args) 434 | 435 | if let service = service { 436 | pthread_mutex_lock(&mutex) 437 | cachedServices[registration.cacheKey] = service 438 | pthread_mutex_unlock(&mutex) 439 | } 440 | 441 | return service 442 | } 443 | 444 | fileprivate var cachedServices = [String : Any](minimumCapacity: 32) 445 | fileprivate var mutex = pthread_mutex_t() 446 | } 447 | 448 | /// Cached services exist for lifetime of the app or until their cache is reset. 449 | public final class ResolverScopeCache: ResolverScopeApplication { 450 | 451 | override public init() { 452 | super.init() 453 | } 454 | 455 | public final func reset() { 456 | pthread_mutex_lock(&mutex) 457 | cachedServices.removeAll() 458 | pthread_mutex_unlock(&mutex) 459 | } 460 | } 461 | 462 | /// Graph services are initialized once and only once during a given resolution cycle. This is the default scope. 463 | public final class ResolverScopeGraph: ResolverScope { 464 | 465 | public init() { 466 | pthread_mutex_init(&mutex, nil) 467 | } 468 | 469 | public final func resolve(resolver: Resolver, registration: ResolverRegistration, args: Any?) -> Service? { 470 | 471 | pthread_mutex_lock(&mutex) 472 | 473 | let existingService = graph[registration.cacheKey] as? Service 474 | 475 | if let service = existingService { 476 | pthread_mutex_unlock(&mutex) 477 | return service 478 | } 479 | 480 | resolutionDepth = resolutionDepth + 1 481 | 482 | pthread_mutex_unlock(&mutex) 483 | 484 | let service = registration.resolve(resolver: resolver, args: args) 485 | 486 | pthread_mutex_lock(&mutex) 487 | 488 | resolutionDepth = resolutionDepth - 1 489 | 490 | if resolutionDepth == 0 { 491 | graph.removeAll() 492 | } else if let service = service, type(of: service as Any) is AnyClass { 493 | graph[registration.cacheKey] = service 494 | } 495 | 496 | pthread_mutex_unlock(&mutex) 497 | 498 | return service 499 | } 500 | 501 | private var graph = [String : Any?](minimumCapacity: 32) 502 | private var resolutionDepth: Int = 0 503 | private var mutex = pthread_mutex_t() 504 | } 505 | 506 | /// Shared services persist while strong references to them exist. They're then deallocated until the next resolve. 507 | public final class ResolverScopeShare: ResolverScope { 508 | 509 | public init() { 510 | pthread_mutex_init(&mutex, nil) 511 | } 512 | 513 | public final func resolve(resolver: Resolver, registration: ResolverRegistration, args: Any?) -> Service? { 514 | pthread_mutex_lock(&mutex) 515 | let existingService = cachedServices[registration.cacheKey]?.service as? Service 516 | pthread_mutex_unlock(&mutex) 517 | 518 | if let service = existingService { 519 | return service 520 | } 521 | 522 | let service = registration.resolve(resolver: resolver, args: args) 523 | 524 | if let service = service, type(of: service as Any) is AnyClass { 525 | pthread_mutex_lock(&mutex) 526 | cachedServices[registration.cacheKey] = BoxWeak(service: service as AnyObject) 527 | pthread_mutex_unlock(&mutex) 528 | } 529 | 530 | return service 531 | } 532 | 533 | public final func reset() { 534 | pthread_mutex_lock(&mutex) 535 | cachedServices.removeAll() 536 | pthread_mutex_unlock(&mutex) 537 | } 538 | 539 | private struct BoxWeak { 540 | weak var service: AnyObject? 541 | } 542 | 543 | private var cachedServices = [String : BoxWeak](minimumCapacity: 32) 544 | private var mutex = pthread_mutex_t() 545 | } 546 | 547 | /// Unique services are created and initialized each and every time they're resolved. 548 | public final class ResolverScopeUnique: ResolverScope { 549 | 550 | public init() {} 551 | public final func resolve(resolver: Resolver, registration: ResolverRegistration, args: Any?) -> Service? { 552 | return registration.resolve(resolver: resolver, args: args) 553 | } 554 | 555 | } 556 | 557 | #if os(iOS) 558 | /// Storyboard Automatic Resolution Protocol 559 | public protocol StoryboardResolving: Resolving { 560 | func resolveViewController() 561 | } 562 | 563 | /// Storyboard Automatic Resolution Trigger 564 | public extension UIViewController { 565 | // swiftlint:disable unused_setter_value 566 | @objc dynamic var resolving: Bool { 567 | get { 568 | return true 569 | } 570 | set { 571 | if let vc = self as? StoryboardResolving { 572 | vc.resolveViewController() 573 | } 574 | } 575 | } 576 | // swiftlint:enable unused_setter_value 577 | } 578 | #endif 579 | 580 | // Swift Property Wrappers 581 | 582 | #if swift(>=5.1) 583 | /// Immediate injection property wrapper. 584 | /// 585 | /// Wrapped dependent service is resolved immediately using Resolver.root upon struct initialization. 586 | /// 587 | @propertyWrapper 588 | public struct Injected { 589 | private var service: Service 590 | public init() { 591 | self.service = Resolver.resolve(Service.self) 592 | } 593 | public init(name: String? = nil, container: Resolver? = nil) { 594 | self.service = container?.resolve(Service.self, name: name) ?? Resolver.resolve(Service.self, name: name) 595 | } 596 | public var wrappedValue: Service { 597 | get { return service } 598 | mutating set { service = newValue } 599 | } 600 | public var projectedValue: Injected { 601 | get { return self } 602 | mutating set { self = newValue } 603 | } 604 | } 605 | 606 | /// Lazy injection property wrapper. Note that mbedded container and name properties will be used if set prior to service instantiation. 607 | /// 608 | /// Wrapped dependent service is not resolved until service is accessed. 609 | /// 610 | @propertyWrapper 611 | public struct LazyInjected { 612 | private var service: Service! 613 | public var container: Resolver? 614 | public var name: String? 615 | public init() {} 616 | public init(name: String? = nil, container: Resolver? = nil) { 617 | self.name = name 618 | self.container = container 619 | } 620 | public var isEmpty: Bool { 621 | return service == nil 622 | } 623 | public var wrappedValue: Service { 624 | mutating get { 625 | if self.service == nil { 626 | self.service = container?.resolve(Service.self, name: name) ?? Resolver.resolve(Service.self, name: name) 627 | } 628 | return service 629 | } 630 | mutating set { service = newValue } 631 | } 632 | public var projectedValue: LazyInjected { 633 | get { return self } 634 | mutating set { self = newValue } 635 | } 636 | public mutating func release() { 637 | self.service = nil 638 | } 639 | } 640 | 641 | @propertyWrapper 642 | public struct OptionalInjected { 643 | private var service: Service? 644 | public init() { 645 | self.service = Resolver.optional(Service.self) 646 | } 647 | public init(name: String? = nil, container: Resolver? = nil) { 648 | self.service = container?.optional(Service.self, name: name) ?? Resolver.optional(Service.self, name: name) 649 | } 650 | public var wrappedValue: Service? { 651 | get { return service } 652 | mutating set { service = newValue } 653 | } 654 | public var projectedValue: OptionalInjected { 655 | get { return self } 656 | mutating set { self = newValue } 657 | } 658 | } 659 | 660 | /// Immediate injection property wrapper for SwiftUI ObservableObjects. This wrapper is meant for use in SwiftUI Views and exposes 661 | /// bindable objects similar to that of SwiftUI @observedObject and @environmentObject. 662 | /// 663 | /// Dependent service must be of type ObservableObject. Updating object state will trigger view update. 664 | /// 665 | /// Wrapped dependent service is resolved immediately using Resolver.root upon struct initialization. 666 | /// 667 | @available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) 668 | @propertyWrapper 669 | public struct InjectedObject: DynamicProperty where Service: ObservableObject { 670 | @ObservedObject private var service: Service 671 | public init() { 672 | self.service = Resolver.resolve(Service.self) 673 | } 674 | public init(name: String? = nil, container: Resolver? = nil) { 675 | self.service = container?.resolve(Service.self, name: name) ?? Resolver.resolve(Service.self, name: name) 676 | } 677 | public var wrappedValue: Service { 678 | get { return service } 679 | mutating set { service = newValue } 680 | } 681 | public var projectedValue: ObservedObject.Wrapper { 682 | return self.$service 683 | } 684 | } 685 | #endif 686 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Common/Rx/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicator.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/5/21. 6 | // 7 | 8 | import Foundation 9 | import RxSwift 10 | import RxCocoa 11 | 12 | public class ActivityIndicator: SharedSequenceConvertibleType { 13 | public typealias Element = Bool 14 | public typealias SharingStrategy = DriverSharingStrategy 15 | 16 | private let _lock = NSRecursiveLock() 17 | private let _behavior = BehaviorRelay(value: false) 18 | private let _loading: SharedSequence 19 | 20 | public init() { 21 | _loading = _behavior.asDriver() 22 | .distinctUntilChanged() 23 | } 24 | 25 | fileprivate func trackActivityOfObservable(_ source: O) -> Observable { 26 | return source.asObservable() 27 | .do(onNext: { _ in 28 | self.sendStopLoading() 29 | }, onError: { _ in 30 | self.sendStopLoading() 31 | }, onCompleted: { 32 | self.sendStopLoading() 33 | }, onSubscribe: subscribed) 34 | } 35 | 36 | private func subscribed() { 37 | _lock.lock() 38 | _behavior.accept(true) 39 | _lock.unlock() 40 | } 41 | 42 | private func sendStopLoading() { 43 | _lock.lock() 44 | _behavior.accept(false) 45 | _lock.unlock() 46 | } 47 | 48 | public func asSharedSequence() -> SharedSequence { 49 | return _loading 50 | } 51 | } 52 | 53 | extension ObservableConvertibleType { 54 | public func trackActivity(_ activityIndicator: ActivityIndicator) -> Observable { 55 | return activityIndicator.trackActivityOfObservable(self) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Common/Rx/ErrorTracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorTracker.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/5/21. 6 | // 7 | 8 | import Foundation 9 | import RxSwift 10 | import RxCocoa 11 | 12 | final class ErrorTracker: SharedSequenceConvertibleType { 13 | typealias SharingStrategy = DriverSharingStrategy 14 | private let _subject = PublishSubject() 15 | 16 | func trackError(from source: O) -> Observable { 17 | return source.asObservable().do(onError: onError) 18 | } 19 | 20 | func asSharedSequence() -> SharedSequence { 21 | return _subject.asObservable().asDriver { error in 22 | return Driver.empty() 23 | } 24 | } 25 | 26 | func asObservable() -> Observable { 27 | return _subject.asObservable() 28 | } 29 | 30 | private func onError(_ error: Error) { 31 | _subject.onNext(error) 32 | } 33 | 34 | deinit { 35 | _subject.onCompleted() 36 | } 37 | } 38 | 39 | extension ObservableConvertibleType { 40 | func trackError(_ errorTracker: ErrorTracker) -> Observable { 41 | return errorTracker.trackError(from: self) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Common/Storyboard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Storyboard.swift 3 | // BaseApp 4 | // 5 | // Created by Dinh Quan on 1/25/21. 6 | // 7 | 8 | import UIKit 9 | 10 | enum StoryboardName: String { 11 | case main = "Main" 12 | case article = "Article" 13 | case user = "User" 14 | } 15 | 16 | struct Storyboard { 17 | static func load(_ name: StoryboardName, type: T.Type, isInitial: Bool? = false) -> T { 18 | let vcName = String(describing: type) 19 | if isInitial == true { 20 | guard let vc = UIStoryboard(name: name.rawValue, bundle: nil).instantiateInitialViewController() as? T else { 21 | fatalError("Cannot initialize storyboard with name \(name.rawValue)") 22 | } 23 | return vc 24 | } 25 | 26 | guard let vc = UIStoryboard(name: name.rawValue, bundle: nil).instantiateViewController(withIdentifier: vcName) as? T else { 27 | fatalError("Cannot initialize storyboard with name \(name.rawValue)") 28 | } 29 | return vc 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Common/Views/Alert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Alert.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import UIKit 9 | 10 | struct Alert { 11 | func show(title: String? = nil, 12 | message: String? = nil, 13 | cancelTitle: String = NSLocalizedString("Ok", comment: ""), 14 | actionTitle: String? = nil, 15 | actionHandler: (() -> Void)? = nil) { 16 | let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) 17 | let cancelAction = UIAlertAction(title: cancelTitle, 18 | style: .cancel, 19 | handler: nil) 20 | alertController.addAction(cancelAction) 21 | 22 | if let actionTitle = actionTitle { 23 | let action = UIAlertAction(title: actionTitle, 24 | style: .default) {_ in 25 | actionHandler?() 26 | } 27 | alertController.addAction(action) 28 | } 29 | 30 | UIViewController.top()?.present(alertController, animated: true, completion: nil) 31 | } 32 | } 33 | 34 | private extension UIViewController { 35 | class func top(controller: UIViewController? = keyWindow?.rootViewController) -> UIViewController? { 36 | if let navigationController = controller as? UINavigationController { 37 | return top(controller: navigationController.visibleViewController) 38 | } 39 | if let tabController = controller as? UITabBarController { 40 | if let selected = tabController.selectedViewController { 41 | return top(controller: selected) 42 | } 43 | } 44 | if let presented = controller?.presentedViewController { 45 | return top(controller: presented) 46 | } 47 | return controller 48 | } 49 | } 50 | 51 | private let keyWindow = UIApplication.shared.keyWindow 52 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Common/Views/Loading.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Loading.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import UIKit 9 | 10 | private var loadingView: UIView? 11 | 12 | struct Loading { 13 | static func show(isFull: Bool = false) { 14 | let keyWindow = UIApplication.shared.keyWindow 15 | 16 | loadingView = UIView(frame: keyWindow?.bounds ?? .zero) 17 | loadingView?.backgroundColor = UIColor.clear 18 | if isFull { 19 | let borderView = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80)) 20 | borderView.layer.cornerRadius = 10 21 | if #available(iOS 13.0, *) { 22 | borderView.backgroundColor = .secondarySystemBackground 23 | } else { 24 | borderView.backgroundColor = .white 25 | } 26 | borderView.center = loadingView?.center ?? .zero 27 | loadingView?.addSubview(borderView) 28 | loadingView?.backgroundColor = UIColor(white: 0, alpha: 0.6) 29 | } 30 | var aiView: UIActivityIndicatorView! 31 | if #available(iOS 13.0, *) { 32 | aiView = UIActivityIndicatorView(style: .medium) 33 | } else { 34 | aiView = UIActivityIndicatorView(style: .gray) 35 | } 36 | aiView.tag = 1 37 | aiView.center = loadingView?.center ?? .zero 38 | loadingView?.addSubview(aiView) 39 | keyWindow?.addSubview(loadingView!) 40 | loadingView?.alpha = 0 41 | UIView.animate(withDuration: 0.1) { 42 | loadingView?.alpha = 1 43 | } 44 | aiView.startAnimating() 45 | } 46 | 47 | static func dismiss() { 48 | guard let aiView = loadingView?.viewWithTag(1) as? UIActivityIndicatorView else { return } 49 | aiView.stopAnimating() 50 | loadingView?.removeFromSuperview() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Common/Views/Toast.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Toast.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import UIKit 9 | 10 | struct Toast { 11 | static func show(_ text: String, duration: Double = 2) { 12 | let keyWindow = UIApplication.shared.keyWindow 13 | 14 | let view = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 44)) 15 | view.backgroundColor = UIColor(white: 0, alpha: 0.75) 16 | view.layer.cornerRadius = 10 17 | let label = UILabel(frame: CGRect(x: 13, y: 0, width: view.bounds.width - 30, height: view.bounds.height)) 18 | label.textColor = .white 19 | label.font = UIFont.systemFont(ofSize: 15) 20 | label.numberOfLines = 2 21 | label.textAlignment = .center 22 | label.text = text 23 | view.addSubview(label) 24 | 25 | guard let window = keyWindow else { return } 26 | view.center = CGPoint(x: window.center.x, y: window.center.y + window.bounds.height/2 - 80) 27 | view.alpha = 0 28 | window.addSubview(view) 29 | UIView.animate(withDuration: 0.2) { 30 | view.alpha = 1 31 | } 32 | 33 | DispatchQueue.main.asyncAfter(deadline: .now() + duration) { 34 | UIView.animate(withDuration: 0.2, animations: { 35 | view.alpha = 0 36 | }, completion: { _ in 37 | view.isHidden = true 38 | view.removeFromSuperview() 39 | }) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Extensions/UITableView+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableView+Extensions.swift 3 | // BaseApp 4 | // 5 | // Created by Dinh Quan on 10/28/2020. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UITableView { 11 | func dequeue(type: Cell.Type, for indexPath: IndexPath) -> Cell where Cell: ReuseID, Cell: UITableViewCell { 12 | let id = Cell.reuseId 13 | guard let cell = dequeueReusableCell(withIdentifier: id, for: indexPath) as? Cell else { 14 | fatalError("Dequeue failed for: \(id), at indexPath: \(indexPath.description)") 15 | } 16 | return cell 17 | } 18 | 19 | func dequeue(type: Cell.Type) -> Cell where Cell: ReuseID, Cell: UITableViewCell { 20 | guard let cell = dequeueReusableCell(withIdentifier: Cell.reuseId) as? Cell else { 21 | fatalError("Dequeue failed for: \(Cell.reuseId)") 22 | } 23 | return cell 24 | } 25 | 26 | func dequeue(type: Cell.Type) -> Cell where Cell: ReuseID, Cell: UITableViewHeaderFooterView { 27 | guard let cell = dequeueReusableHeaderFooterView(withIdentifier: Cell.reuseId) as? Cell else { 28 | fatalError("HeaderFooter dequeue failed for: \(Cell.reuseId)") 29 | } 30 | return cell 31 | } 32 | } 33 | 34 | extension UITableView { 35 | func createCell(_ type: Cell.Type, _ viewModel: ViewModel, _ indexPath: IndexPath) -> Cell 36 | where Cell: UITableViewCell, Cell: CellViewModelProtocol, Cell.ViewModel == ViewModel { 37 | let cell = dequeue(type: Cell.self, for: indexPath) 38 | cell.config(with: viewModel) 39 | return cell 40 | } 41 | 42 | func register(type: Cell.Type, identifier: String? = nil, nibName: String? = nil, bundle: Bundle = .main) { 43 | let cellName = String(describing: type) 44 | let cellIdentifier = identifier ?? cellName 45 | let cellNibName = nibName ?? cellName 46 | register(UINib(nibName: cellNibName, bundle: bundle), forCellReuseIdentifier: cellIdentifier) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Extensions/UITableView+Rx.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITableView+Rx.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import UIKit 9 | import RxSwift 10 | import RxCocoa 11 | 12 | extension RxSwift.Reactive where Base: UITableView { 13 | var onReachedEnd: Observable { 14 | return base.rx 15 | .didScroll 16 | .throttle(.milliseconds(400), scheduler: MainScheduler.instance) 17 | .map { [weak base] in 18 | guard let base = base else { return false } 19 | if base.contentOffset.y + base.frame.size.height - 20 >= base.contentSize.height { 20 | return true 21 | } 22 | return false 23 | } 24 | .filter { $0 } 25 | .map { _ in } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Extensions/ViewState+Rx.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewState+Rx.swift 3 | // EcommerceBase 4 | // 5 | // Created by Dinh Quan on 1/28/21. 6 | // 7 | 8 | import UIKit 9 | import RxSwift 10 | import RxCocoa 11 | 12 | /// ViewController view states. 13 | enum ViewControllerViewState: Equatable { 14 | case viewWillAppear 15 | case viewDidAppear 16 | case viewWillDisappear 17 | case viewDidDisappear 18 | case viewDidLoad 19 | case viewDidLayoutSubviews 20 | case willMove 21 | case didMove 22 | } 23 | 24 | /// UIViewController view state changes. 25 | /// Emits a Bool value indicating whether the change was animated or not. 26 | extension RxSwift.Reactive where Base: UIViewController { 27 | var viewDidLoad: Observable { 28 | methodInvoked(#selector(UIViewController.viewDidLoad)) 29 | .map { _ in } 30 | } 31 | 32 | var viewDidLayoutSubviews: Observable { 33 | methodInvoked(#selector(UIViewController.viewDidLayoutSubviews)) 34 | .map { _ in } 35 | } 36 | 37 | var viewWillAppear: Observable { 38 | methodInvoked(#selector(UIViewController.viewWillAppear)) 39 | .map { $0.first as? Bool ?? false } 40 | } 41 | 42 | var viewDidAppear: Observable { 43 | methodInvoked(#selector(UIViewController.viewDidAppear)) 44 | .map { $0.first as? Bool ?? false } 45 | } 46 | 47 | var viewWillDisappear: Observable { 48 | methodInvoked(#selector(UIViewController.viewWillDisappear)) 49 | .map { $0.first as? Bool ?? false } 50 | } 51 | 52 | var viewDidDisappear: Observable { 53 | methodInvoked(#selector(UIViewController.viewDidDisappear)) 54 | .map { $0.first as? Bool ?? false } 55 | } 56 | 57 | var willMove: Observable { 58 | methodInvoked(#selector(UIViewController.willMove(toParent:))) 59 | .map { $0.first as? UIViewController? ?? nil } 60 | } 61 | 62 | var didMove: Observable { 63 | methodInvoked(#selector(UIViewController.didMove(toParent:))) 64 | .map { $0.first as? UIViewController? ?? nil } 65 | } 66 | 67 | /// Observable sequence of the view controller's view state. 68 | /// This gives you an observable sequence of all possible states. 69 | /// - returns: Observable sequence of AppStates. 70 | var viewState: Observable { 71 | Observable.of( 72 | viewDidLoad.map { _ in ViewControllerViewState.viewDidLoad }, 73 | viewDidLayoutSubviews.map { _ in ViewControllerViewState.viewDidLayoutSubviews }, 74 | viewWillAppear.map { _ in ViewControllerViewState.viewWillAppear }, 75 | viewDidAppear.map { _ in ViewControllerViewState.viewDidAppear }, 76 | viewWillDisappear.map { _ in ViewControllerViewState.viewWillDisappear }, 77 | viewDidDisappear.map { _ in ViewControllerViewState.viewDidDisappear } 78 | ) 79 | .merge() 80 | } 81 | } 82 | 83 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Scenes/Article/Article.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 51 | 57 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 145 | 151 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Scenes/Article/ArticleDetail/ArticleDetailViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArticleDetailViewController.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import UIKit 9 | 10 | final class ArticleDetailViewController: UIViewController { 11 | @IBOutlet weak var titleLabel: UILabel! 12 | @IBOutlet weak var descriptionLabel: UILabel! 13 | @IBOutlet weak var timeLabel: UILabel! 14 | @IBOutlet weak var thumbnailImageView: UIImageView! 15 | 16 | var viewModel: ArticleDetailViewModel! 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | bind() 21 | } 22 | 23 | private func bind() { 24 | let article = viewModel.article 25 | title = article.title 26 | titleLabel.text = article.title 27 | descriptionLabel.text = article.description 28 | timeLabel.text = article.formattedPublishedAt 29 | if let url = URL(string: article.urlToImage) { 30 | thumbnailImageView.kf.setImage(with: url) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Scenes/Article/ArticleDetail/ArticleDetailViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewsDetailViewModel.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct ArticleDetailViewModel { 11 | let article: Article 12 | } 13 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Scenes/Article/ArticleList/ArticleListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArticleListViewController.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import UIKit 9 | import RxSwift 10 | import RxDataSources 11 | 12 | final class ArticleListViewController: UIViewController { 13 | @IBOutlet weak var tableView: UITableView! 14 | @IBOutlet weak var searchBar: UISearchBar! 15 | @IBOutlet weak var noArticlesLabel: UILabel! 16 | @IBOutlet weak var loadMoreIndicator: UIActivityIndicatorView! 17 | 18 | private let bag = DisposeBag() 19 | 20 | var viewModel: ArticleListViewModel! 21 | var navigator: ArticleNavigator! 22 | 23 | override func viewDidLoad() { 24 | super.viewDidLoad() 25 | title = NSLocalizedString("News", comment: "") 26 | bind() 27 | } 28 | 29 | private func bind() { 30 | let dataSource = RxTableViewSectionedReloadDataSource(configureCell: { ds, tv, ip, _ in 31 | switch ds[ip] { 32 | case let .article(viewModel): 33 | return tv.createCell(ArticleCell.self, viewModel, ip) 34 | } 35 | }) 36 | 37 | let search = searchBar.rx 38 | .searchButtonClicked.map { [weak self] Void -> String in 39 | return self?.searchBar.text ?? "" 40 | } 41 | .do(onNext: { [weak self] _ in 42 | self?.view.endEditing(true) 43 | }) 44 | .filter { $0.count > 0 } 45 | .asObservable() 46 | 47 | let loadMore = tableView.rx 48 | .onReachedEnd 49 | .do(onNext: { [weak self] in 50 | self?.loadMoreIndicator.isHidden = false 51 | self?.loadMoreIndicator.startAnimating() 52 | }) 53 | 54 | let input = ArticleListViewModel.Input(search: search, 55 | loadMore: loadMore) 56 | let output = viewModel.transform(input: input) 57 | 58 | output.tableData 59 | .drive(tableView.rx.items(dataSource: dataSource)) 60 | .disposed(by: bag) 61 | 62 | tableView.rx 63 | .modelSelected(ArticleListViewModel.SectionItem.self) 64 | .subscribe(onNext: { [weak self] item in 65 | guard let self = self else { return } 66 | switch item { 67 | case .article(let vm): 68 | self.navigator.showArticleDetail(article: vm.article) 69 | } 70 | }) 71 | .disposed(by: bag) 72 | 73 | tableView.rx 74 | .itemSelected 75 | .subscribe(onNext: { [weak self] indexPath in 76 | self?.tableView.deselectRow(at: indexPath, animated: true) 77 | }) 78 | .disposed(by: bag) 79 | 80 | output.tableData 81 | .map { !($0.first?.items.isEmpty ?? false) } 82 | .drive(noArticlesLabel.rx.isHidden) 83 | .disposed(by: bag) 84 | 85 | output.fetching 86 | .drive(onNext: { [weak self] isFetching in 87 | if isFetching { 88 | Loading.show() 89 | } else { 90 | Loading.dismiss() 91 | self?.loadMoreIndicator.isHidden = true 92 | self?.loadMoreIndicator.stopAnimating() 93 | } 94 | }) 95 | .disposed(by: bag) 96 | 97 | output.error 98 | .drive(onNext: { error in 99 | Toast.show(error.localizedDescription) 100 | }) 101 | .disposed(by: bag) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Scenes/Article/ArticleList/ArticleListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArticleListViewModel.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import RxSwift 9 | import RxCocoa 10 | import RxDataSources 11 | 12 | struct ArticleListViewModel: ViewModelProtocol { 13 | struct Input { 14 | let search: Observable 15 | let loadMore: Observable 16 | } 17 | 18 | struct Output { 19 | let tableData: Driver<[SectionModel]> 20 | let fetching: Driver 21 | let error: Driver 22 | } 23 | 24 | @Injected var articleUseCase: ArticleUseCase 25 | 26 | private let bag = DisposeBag() 27 | private let pageSize = 20 28 | 29 | func transform(input: Input) -> Output { 30 | let currentPage = BehaviorRelay(value: 1) 31 | let allArticles = BehaviorRelay<[Article]>(value: []) 32 | 33 | let loadMore = input.loadMore 34 | .do(onNext: { 35 | let nextPage = currentPage.value + 1 36 | currentPage.accept(nextPage) 37 | }) 38 | .withLatestFrom(input.search) 39 | 40 | let search = input.search 41 | .do(onNext: { _ in 42 | currentPage.accept(1) 43 | }) 44 | 45 | let activityTracker = ActivityIndicator() 46 | let errorTracker = ErrorTracker() 47 | 48 | Observable.merge(search, loadMore) 49 | .flatMapLatest { keyword in 50 | return articleUseCase 51 | .findArticlesByKeyword(keyword, pageSize: pageSize, page: currentPage.value) 52 | .trackActivity(activityTracker) 53 | .trackError(errorTracker) 54 | .asDriver(onErrorJustReturn: []) 55 | } 56 | .subscribe(onNext: { articles in 57 | var appendedArticles = allArticles.value 58 | if (currentPage.value == 1) { 59 | appendedArticles.removeAll() 60 | } 61 | appendedArticles.append(contentsOf: articles) 62 | allArticles.accept(appendedArticles) 63 | }) 64 | .disposed(by: bag) 65 | 66 | let sectionItems = allArticles.map { articles -> [SectionItem] in 67 | return articles.map { 68 | SectionItem.article(viewModel: ArticleCellViewModel(article: $0)) 69 | } 70 | } 71 | 72 | let tableData = sectionItems 73 | .map { [SectionModel(items: $0)] } 74 | .asDriver(onErrorJustReturn: []) 75 | 76 | return Output(tableData: tableData, 77 | fetching: activityTracker.asDriver(), 78 | error: errorTracker.asDriver()) 79 | } 80 | } 81 | 82 | extension ArticleListViewModel { 83 | struct SectionModel { 84 | var items: [SectionItem] 85 | } 86 | 87 | enum SectionItem { 88 | case article(viewModel: ArticleCellViewModel) 89 | } 90 | } 91 | 92 | extension ArticleListViewModel.SectionModel: SectionModelType { 93 | typealias Item = ArticleListViewModel.SectionItem 94 | 95 | init(original: ArticleListViewModel.SectionModel, items: [Item]) { 96 | self = original 97 | self.items = items 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Scenes/Article/ArticleList/Views/ArticleCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArticleCell.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import UIKit 9 | import Kingfisher 10 | 11 | final class ArticleCell: UITableViewCell { 12 | @IBOutlet weak var titleLabel: UILabel! 13 | @IBOutlet weak var descriptionLabel: UILabel! 14 | @IBOutlet weak var timeLabel: UILabel! 15 | @IBOutlet weak var thumbnailImageView: UIImageView! 16 | } 17 | 18 | extension ArticleCell: CellViewModelProtocol { 19 | func config(with vm: ArticleCellViewModel) { 20 | let article = vm.article 21 | titleLabel.text = article.title 22 | descriptionLabel.text = article.description 23 | timeLabel.text = article.formattedPublishedAt 24 | if let url = URL(string: article.urlToImage) { 25 | thumbnailImageView.kf.setImage(with: url) 26 | } 27 | } 28 | } 29 | 30 | struct ArticleCellViewModel { 31 | let article: Article 32 | } 33 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Scenes/Article/ArticleNavigator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewsNavigator.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import UIKit 9 | 10 | struct ArticleNavigator { 11 | let navigationController: UINavigationController 12 | 13 | func showArticles() { 14 | let articleListViewController = Storyboard.load(.article, type: ArticleListViewController.self) 15 | articleListViewController.viewModel = ArticleListViewModel() 16 | articleListViewController.navigator = self 17 | navigationController.pushViewController(articleListViewController, animated: false) 18 | } 19 | 20 | func showArticleDetail(article: Article) { 21 | let articleDetailViewController = Storyboard.load(.article, type: ArticleDetailViewController.self) 22 | articleDetailViewController.viewModel = ArticleDetailViewModel(article: article) 23 | navigationController.pushViewController(articleDetailViewController, animated: true) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CleanArchitecture/App/Theme/Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/5/21. 6 | // 7 | 8 | import UIKit 9 | 10 | struct Color { 11 | static let main = UIColor(hex: 0x0F978A) 12 | static let darkText = UIColor(hex: 0x343434) 13 | static let lightText = UIColor(hex: 0x888888) 14 | static let lightBG = UIColor(hex: 0xEEEEEE) 15 | static let separator = UIColor(hex: 0xF2F2F2) 16 | } 17 | 18 | public extension UIColor { 19 | convenience init(red: Int, green: Int, blue: Int) { 20 | assert(red >= 0 && red <= 255, "Invalid red component") 21 | assert(green >= 0 && green <= 255, "Invalid green component") 22 | assert(blue >= 0 && blue <= 255, "Invalid blue component") 23 | 24 | self.init(red: CGFloat(red) / 255.0, green: CGFloat(green) / 255.0, blue: CGFloat(blue) / 255.0, alpha: 1.0) 25 | } 26 | 27 | convenience init(hex: Int, alpha: CGFloat = 1.0) { 28 | self.init(red: CGFloat((hex >> 16) & 0xff) / CGFloat(255.0), 29 | green: CGFloat((hex >> 8) & 0xff) / CGFloat(255.0), 30 | blue: CGFloat(hex & 0xff) / CGFloat(255.0), 31 | alpha: alpha) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CleanArchitecture/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Dinh Quan on 2/21/21. 6 | // 7 | 8 | import UIKit 9 | 10 | @main 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | var window: UIWindow? 14 | 15 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 16 | 17 | setUpRootView() 18 | configUI() 19 | 20 | return true 21 | } 22 | 23 | private func setUpRootView() { 24 | let window = UIWindow(frame: UIScreen.main.bounds) 25 | let navigationController = Storyboard.load(.main, type: UINavigationController.self, isInitial: true) 26 | 27 | let articleNavigator = ArticleNavigator(navigationController: navigationController) 28 | articleNavigator.showArticles() 29 | 30 | window.rootViewController = navigationController 31 | window.makeKeyAndVisible() 32 | 33 | self.window = window 34 | } 35 | 36 | private func configUI() { 37 | if #available(iOS 13.0, *) { 38 | window?.overrideUserInterfaceStyle = .light 39 | } 40 | 41 | UINavigationBar.appearance().barTintColor = Color.main 42 | UINavigationBar.appearance().tintColor = UIColor.white 43 | UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: UIColor.white] 44 | } 45 | } 46 | 47 | extension Resolver: ResolverRegistering { 48 | public static func registerAllServices() { 49 | register { 50 | ArticleRepository() as ArticleUseCase 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /CleanArchitecture/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /CleanArchitecture/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 | -------------------------------------------------------------------------------- /CleanArchitecture/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CleanArchitecture/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 | -------------------------------------------------------------------------------- /CleanArchitecture/Base.lproj/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 | -------------------------------------------------------------------------------- /CleanArchitecture/Data/Config/Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Config.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol EnvConfiguration { 11 | var baseUrl: String { get } 12 | var apiKey: String { get } 13 | var mockEnabled: Bool { get } 14 | } 15 | 16 | struct Config { 17 | // TODO: automatically environment switch by Bundle Id 18 | static let current = Dev() 19 | 20 | struct Dev: EnvConfiguration { 21 | let baseUrl = "http://newsapi.org/v2" 22 | let apiKey = "ff5445a21c1d44c4928c1c3f0e7ed0f6" 23 | let mockEnabled = false 24 | } 25 | 26 | struct Prod: EnvConfiguration { 27 | let baseUrl = "http://newsapi.org/v2" 28 | let apiKey = "ff5445a21c1d44c4928c1c3f0e7ed0f6" 29 | let mockEnabled = false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /CleanArchitecture/Data/Network/ArticleService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewsService.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum ArticleService: RestAPI { 11 | case searchArticlesByKeyword(q: String, pageSize: Int, page: Int) 12 | 13 | var path: String { 14 | switch self { 15 | case .searchArticlesByKeyword(let q, let pageSize, let page): 16 | let encodedQ = q.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? "" 17 | return "everything?q=\(encodedQ)&from=2021-02-01&sortBy=publishedAt&apiKey=\(Config.current.apiKey)&pageSize=\(pageSize)&page=\(page)" 18 | } 19 | } 20 | 21 | var method: HTTPMethod { 22 | switch self { 23 | case .searchArticlesByKeyword: 24 | return .get 25 | } 26 | } 27 | 28 | var mockFile: String { 29 | switch self { 30 | case .searchArticlesByKeyword: 31 | return "searchArticles.json" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /CleanArchitecture/Data/Network/Mock/searchArticles.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "status": "ok", 4 | "totalResults": 18346, 5 | "articles": [ 6 | { 7 | "source": { 8 | "id": null, 9 | "name": "Cnbeta.com" 10 | }, 11 | "author": null, 12 | "title": "特斯拉砍掉了Model Y的长续航后驱版本", 13 | "description": "Teslarati 周二报道称,特斯拉已经砍掉了 Model Y 的长续航后驱版本,目前销售人员正在向预订客户通报此事,并要求变更为其它配置。起初,马斯克宣称 Model Y 标准后驱版的续航里程太低,因而决定用长续航后驱版来弥补。然而上个月的时候,该公司又透露了 Model Y 标准续航 / 后驱版本的存在,并于近日开启了销售。\r\n资料图(来自:Tesla)\r\n尽管特斯拉习惯了悄悄改变在售产品的阵容,但对于 Model Y 标准续航版的摇摆不定,该公司公关部门尚未回应外媒的置评请求。\r\n目前官网上可选的 Mod…", 14 | "url": "http://www.cnbeta.com/articles/tech/1086657.htm", 15 | "urlToImage": "https://static.cnbetacdn.com/article/2021/0204/9b6e5de767acb78.jpg", 16 | "publishedAt": "2021-02-04T02:37:06Z", 17 | "content": "Teslarati Model Y Model Y Model Y /" 18 | }, 19 | { 20 | "source": { 21 | "id": null, 22 | "name": "MarketWatch" 23 | }, 24 | "author": "Mike Murphy", 25 | "title": "Don’t buy a Tesla during a production ramp, Elon Musk warns", 26 | "description": "In a new interview, Elon Musk agreed with some scathing criticism of Telsa Inc. vehicles, and advised against buying Tesla vehicles during a production ramp-up.", 27 | "url": "https://www.marketwatch.com/story/dont-buy-a-tesla-during-a-production-ramp-elon-musk-warns-11612406222", 28 | "urlToImage": "https://images.mktw.net/im-294744/social", 29 | "publishedAt": "2021-02-04T02:37:00Z", 30 | "content": "In a new interview, Elon Musk agreed with some scathing criticism of Telsa Inc. vehicles, and advised against buying Tesla vehicles during a production ramp-up.The extraordinarily candid interview ev… [+2308 chars]" 31 | }, 32 | { 33 | "source": { 34 | "id": null, 35 | "name": "New York Post" 36 | }, 37 | "author": "Elizabeth Elizalde", 38 | "title": "Elon Musk says Neuralink could start implanting chips in human brains ‘later this year’", 39 | "description": "Elon Musk says that his neurotechnology company Neuralink could be launching human trials “later this year.” Musk shared the news in response to a Twitter user who said he’d be willing to participate in the human trials, which would implant an artificial inte…", 40 | "url": "https://nypost.com/2021/02/03/elon-musk-neuralink-could-start-implanting-brain-chips-later-this-year/", 41 | "urlToImage": "https://nypost.com/wp-content/uploads/sites/2/2021/02/Elon-Musk.jpg?quality=90&strip=all&w=1200", 42 | "publishedAt": "2021-02-04T02:33:40Z", 43 | "content": "Elon Musk says that his neurotechnology company Neuralink could be launching human trials “later this year.” \r\nMusk shared the news in response to a Twitter user who said he’d be willing to participa… [+1314 chars]" 44 | }, 45 | { 46 | "source": { 47 | "id": null, 48 | "name": "CleanTechnica" 49 | }, 50 | "author": "Scott Cooney", 51 | "title": "Energy Efficiency Before Going Solar: How Much Difference In The Cost Does It Make?", 52 | "description": "Efficiency combined with solar changes the equation dramatically. The difference for me was roughly 2/3 the cost of a new Tesla Model 3.", 53 | "url": "https://cleantechnica.com/2021/02/03/efficiency-before-solar-how-much-cost-difference-will-it-make/", 54 | "urlToImage": "https://cleantechnica.com/files/2020/09/tesla-solar-roof-energy-home-residential-BIPV-tiles-solarglass-CHUCK-KYLE-construction-installation-10-scaled.jpg", 55 | "publishedAt": "2021-02-04T02:30:48Z", 56 | "content": "BuildingsPublished on February 3rd, 2021 |\r\n by Scott Cooney\r\n0\r\nFebruary 3rd, 2021 by Scott Cooney \r\nRooftop or on-site solar can often generate enough power for homes, businesses, and industry, hel… [+4867 chars]" 57 | }, 58 | { 59 | "source": { 60 | "id": null, 61 | "name": "Space Daily" 62 | }, 63 | "author": null, 64 | "title": "Tech billionaire Elon Musk says he's off Twitter 'for a while'", 65 | "description": "New York (AFP) Feb 2, 2021\n\n\n Tech billionaire Elon Musk said Tuesday he was taking a break from Twitter \"for a while\", after his posts on the platform helped fuel a stock market frenzy that sent the share prices of several companies soaring. \n\nMusk overtook …", 66 | "url": "https://www.spacedaily.com/reports/Tech_billionaire_Elon_Musk_says_hes_off_Twitter_for_a_while_999.html", 67 | "urlToImage": "https://www.spxdaily.com/images-hg/twitter-bomb-hg.jpg", 68 | "publishedAt": "2021-02-04T02:23:15Z", 69 | "content": "Tech billionaire Elon Musk said Tuesday he was taking a break from Twitter \"for a while\", after his posts on the platform helped fuel a stock market frenzy that sent the share prices of several compa… [+2788 chars]" 70 | }, 71 | { 72 | "source": { 73 | "id": null, 74 | "name": "Space Daily" 75 | }, 76 | "author": null, 77 | "title": "Amazon's Bezos, latest tycoon to pursue his 'passion'", 78 | "description": "Washington (AFP) Feb 3, 2021\n\n\n Bill Gates set out to heal the world. His Microsoft co-founder Paul Allen bought sports teams. Ted Turner raced yachts. And Donald Trump went into politics. \n\nAmazon founder Jeff Bezos, the world's richest man, plans to build r…", 79 | "url": "https://www.spacedaily.com/reports/Amazons_Bezos_latest_tycoon_to_pursue_his_passion_999.html", 80 | "urlToImage": "https://www.spxdaily.com/images-hg/jeff-bezos-new-shepard-hg.jpg", 81 | "publishedAt": "2021-02-04T02:23:15Z", 82 | "content": "Bill Gates set out to heal the world. His Microsoft co-founder Paul Allen bought sports teams. Ted Turner raced yachts. And Donald Trump went into politics.\r\nAmazon founder Jeff Bezos, the world's ri… [+4947 chars]" 83 | }, 84 | { 85 | "source": { 86 | "id": null, 87 | "name": "Cool3c.com" 88 | }, 89 | "author": "中央社", 90 | "title": "未來連解鎖iPhone都不用就可以直接開門啟動特斯拉電動車", 91 | "description": "UWB技術在iPhone 11的時候發表,可以透過UWB確定附近是否還有其他iPhone,現在特斯拉透過這個技術讓iPhone成為更完整的數位鑰匙,把手機放在身上直接啟動特斯拉非常方便。 電動車大廠特斯拉(Tesla)新車款設計傳曝光,外媒報導特斯拉規劃採用UWB技術,可讓iPhone變身「數位鑰匙」,更精確定位電動車位置,車主不用掏出iPhone就可無線開鎖,使用上比既有App軟體更具安全性。 國外科技網站The Verge報導,提交給美國聯邦傳播委員會(Federal Communications Commi…", 92 | "url": "https://www.cool3c.com/article/159688", 93 | "urlToImage": "https://sw.cool3c.com/user/6/2021/4a22566c-f943-4cf4-9ccf-2ca42beb785c.jpg?fit=max&w=1400&q=80", 94 | "publishedAt": "2021-02-04T02:23:11Z", 95 | "content": "UWBiPhone 11UWBiPhoneiPhone\r\nTeslaUWBiPhoneiPhoneApp\r\nThe VergeFederal Communications CommissionUWBUltra Wide Band\r\n63UWB\r\nUWBiPhoneSamsungUWB\r\niPhoneUWBiPhoneiPhoneUWB\r\nMacRumorsU1iPhone 115GiPhone … [+118 chars]" 96 | }, 97 | { 98 | "source": { 99 | "id": "rt", 100 | "name": "RT" 101 | }, 102 | "author": "RT en Español", 103 | "title": "Elon Musk asegura que las pruebas de implantes cerebrales de Neuralink en humanos podrían iniciar este año", 104 | "description": "El director ejecutivo de Tesla señaló que uno de los objetivos del proyecto es resolver las lesiones cerebrales y de columna vertebral.", 105 | "url": "https://actualidad.rt.com/actualidad/382417-elon-musk-asegura-pruebas-humanos-implantes-cerebrales-iniciar-finales-ano", 106 | "urlToImage": "https://cdni.rt.com/actualidad/public_images/2021.02/article/601aef86e9ff716fa040ff98.JPG", 107 | "publishedAt": "2021-02-04T02:21:43Z", 108 | "content": "El director ejecutivo de Tesla y SpaceX, Elon Musk, reveló este martes que la compañía de neurotecnología Neuralink podría empezar a implantar chips de computadora en humanos a finales de este año, c… [+1359 chars]" 109 | }, 110 | { 111 | "source": { 112 | "id": null, 113 | "name": "CleanTechnica" 114 | }, 115 | "author": "Jo Borrás", 116 | "title": "Lordstown Motors Electric Pickup Will Use “Tesla Batteries” From LG", 117 | "description": "Lordstown Motors' electric pickup truck will use the same \"Tesla Batteries\" from LG Chem found in the Model 3 and Model Y, and it's making an RV, too!", 118 | "url": "https://cleantechnica.com/2021/02/03/lordstown-motors-electric-pickup-will-use-tesla-batteries-from-lg/", 119 | "urlToImage": "https://cleantechnica.com/files/2021/02/1612389422978blob-e1612391672954.png", 120 | "publishedAt": "2021-02-04T02:20:51Z", 121 | "content": "BatteriesPublished on February 3rd, 2021 |\r\n by Jo Borrás\r\n0\r\nFebruary 3rd, 2021 by Jo Borrás \r\nLordstown Motors recently revealed a new round of commercial, operational, and strategic development up… [+3415 chars]" 122 | }, 123 | { 124 | "source": { 125 | "id": null, 126 | "name": "Cnbeta.com" 127 | }, 128 | "author": null, 129 | "title": "马斯克说了实话:别在生产旺季购买特斯拉,品控差一些", 130 | "description": "2月4日,美国电动汽车制造商特斯拉首席执行官埃隆·马斯克(Elon Musk)在当地时间周二接受采访时表示,不要在生产旺季购买特斯拉汽车。他还说,要么一开始就买,要么等产量稳定下来以后再买。马斯克接受采访时承认,一旦公司大幅扩大产能,特斯拉电动汽车的质量就会受到影响。马斯克还表示,特斯拉在接近2020年底时生产汽车的速度太快了,以至于汽车漆面出现了不同程度的问题。\r\n视频截图\r\n特斯拉因汽车质量问题受到了很多批评,Model 3和Model Y开始提高产量后更是如此。但在接受行业分析师桑迪·门罗(Sandy Mu…", 131 | "url": "http://www.cnbeta.com/articles/tech/1086635.htm", 132 | "urlToImage": "https://static.cnbetacdn.com/article/2021/0204/b5dc21ef11bf942.jpg", 133 | "publishedAt": "2021-02-04T02:13:38Z", 134 | "content": "[]Galaxy S21 UltraiPhone 12 Pro Max \r\n2021-02-04 220" 135 | }, 136 | { 137 | "source": { 138 | "id": null, 139 | "name": "Cnbeta.com" 140 | }, 141 | "author": "raymon725", 142 | "title": "马斯克说了实话:别在生产旺季购买特斯拉,品控差一些", 143 | "description": "2月4日,美国电动汽车制造商特斯拉首席执行官埃隆·马斯克(Elon Musk)在当地时间周二接受采访时表示,不要在生产旺季购买特斯拉汽车。他还说,要么一开始就买,要么等产量稳定下来以后再买。马斯克接受采访时承认,一旦公司大幅扩大产能,特斯拉电动汽车的质量就会受到影响。马斯克还表示,特斯拉在接近2020年底时生产汽车的速度太快了,以至于汽车漆面出现了不同程度的问题。 阅读全文", 144 | "url": "https://www.cnbeta.com/articles/tech/1086635.htm", 145 | "urlToImage": "https://static.cnbetacdn.com/article/2021/0204/b5dc21ef11bf942.jpg", 146 | "publishedAt": "2021-02-04T02:13:38Z", 147 | "content": "Model 3Model Y·(Sandy Munro)\r\n:?:\r\nTesla \r\nModel 312\r\nModel 3202050\r\nElon Musk Interview 1on1 with Sandy Munrovia\r\n2018Model 390:\r\nModel YAutopilot" 148 | }, 149 | { 150 | "source": { 151 | "id": null, 152 | "name": "Hotnews.ro" 153 | }, 154 | "author": "https://www.facebook.com/www.hotnews.ro", 155 | "title": "​Business report: Șoferii își vor putea cumpăra RCA-ul direct de la stat. Cele mai dulci roșii din lume. De ce nordicii au mai mulţi copii decât cei din sud. Somatii pentru debite deja achitate, trimise pe banda rulanta", 156 | "description": "Firmele care nu respectă regulile stabilite în contextul pandemiei ar putea fi suspendate ● ​De ce europenii din nord au mai mulţi copii decât cei din sud ● Somatii pentru debite deja achitate, trimise pe banda rulanta ● România, țara care importă deșeuri făr…", 157 | "url": "https://economie.hotnews.ro/stiri-finante_banci-24582586-business-report-soferii-isi-vor-putea-cumpara-rca-direct-stat-nordicii-mai-multi-copii-decat-cei-din-sud-somatii-pentru-debite-deja-achitate-trimise-banda-rulanta-cele-mai-dulci-rosii-din-lume.htm", 158 | "urlToImage": "http://media.hotnews.ro/media_server1/image-2020-03-20-23737457-70-business-report.jpg", 159 | "publishedAt": "2021-02-04T02:13:00Z", 160 | "content": "Firmele care nu respect regulile stabilite în contextul pandemiei ar putea fi suspendate De ce europenii din nord au mai muli copii decât cei din sud Somatii pentru debite deja achitate, trimise pe b… [+20640 chars]" 161 | }, 162 | { 163 | "source": { 164 | "id": null, 165 | "name": "Observador.pt" 166 | }, 167 | "author": "Alfredo Lavrador", 168 | "title": "As boas opções (e as menos boas) dos Tesla Model S e X", 169 | "description": "O novo Model S trouxe consigo uma série de novidades mas, como é habitual na Tesla, umas foram aplaudidas enquanto outras causaram alguma polémica. Veja aqui as vantagens e desvantagens de cada uma.", 170 | "url": "https://observador.pt/2021/02/04/as-boas-opcoes-e-as-menos-boas-do-tesla-model-s/", 171 | "urlToImage": "https://wm.observador.pt/wm/obs/l/https%3A%2F%2Fbordalo.observador.pt%2Fv2%2Frs%3Afill%3A770%3A403%2Fc%3A1920%3A1079%3Anowe%3A0%3A0%2Fq%3A85%2Fplain%2Fhttps%3A%2F%2Fs3.observador.pt%2Fwp-content%2Fuploads%2F2021%2F02%2F04011507%2F2021-tesla-model-s-plaid.jpg", 172 | "publishedAt": "2021-02-04T02:00:48Z", 173 | "content": "Se os clientes da marca esperavam um restyling do Model S (e do Model X), a Tesla surpreendeu-os com uma mão cheia de novidades, mais do que poderiam ambicionar. Mas se a maioria destes trunfos ofere… [+7730 chars]" 174 | }, 175 | { 176 | "source": { 177 | "id": null, 178 | "name": "Gizmodo Australia" 179 | }, 180 | "author": "Jason Torchinsky", 181 | "title": "Time To Talk About That Bonkers Beetle/Subaru Mash-Up That’s Been Around Forever", 182 | "description": "You know how there’s some remarkable cars that just sort of perpetually bounce around the collective consciousness of gearheads, periodically re-emerging to once again dazzle/delight/disgust a new crop of car-fetishists? Of course you do. One of the most endu…", 183 | "url": "https://www.gizmodo.com.au/2021/02/time-to-talk-about-that-bonkers-beetle-subaru-mash-up-thats-been-around-forever/", 184 | "urlToImage": "https://imgix.gizmodo.com.au/content/uploads/sites/2/2021/02/03/lwmo4atqjhujhkd1um13.png?ar=16%3A9&auto=format&fit=crop&q=65&w=1280", 185 | "publishedAt": "2021-02-04T02:00:45Z", 186 | "content": "You know how theres some remarkable cars that just sort of perpetually bounce around the collective consciousness of gearheads, periodically re-emerging to once again dazzle/delight/disgust a new cro… [+4452 chars]" 187 | }, 188 | { 189 | "source": { 190 | "id": null, 191 | "name": "Newsmth.net" 192 | }, 193 | "author": "KevinYuan158", 194 | "title": "[Go编程语言] 【特斯拉】-golang-北京/上海-42K-84K", 195 | "description": "发信人: KevinYuan158 (KevinYuan158), 信区: Golang标 题: 【特斯拉】-golang-北京/上海-42K-84K发信站: 水木社区 (Mon Jan 25 10:44:14 2021), 站内Senior Software EngineerRoleTesla is looking for a strong Software Engineer to design and develop software for its current and next generations …", 196 | "url": "http://www.newsmth.net/bbstcon.php?board=Golang&gid=4318", 197 | "urlToImage": null, 198 | "publishedAt": "2021-02-04T01:37:02Z", 199 | "content": null 200 | }, 201 | { 202 | "source": { 203 | "id": null, 204 | "name": "Finance.ua" 205 | }, 206 | "author": "autonews.ua", 207 | "title": "Tesla отзывает 135 000 электромобилей", 208 | "description": "Компания Tesla отзывает в США 135 000 электромобилей из-за сбоя в программном обеспечении. Будут ли эти машины отозваны в других странах – пока не известно.", 209 | "url": "https://news.finance.ua/ru/news/-/486776/tesla-otzyvaet-135-000-elektromobilej", 210 | "urlToImage": "http://resources.finance.ua/ru/news/image-repost?id=486776", 211 | "publishedAt": "2021-02-04T01:36:00Z", 212 | "content": "Tesla 135 000 - . .\r\n , Tesla Model S 2012-2018 Model X 2016-2018 .\r\n - , , . - , .\r\n - , . .\r\n , , . , ." 213 | }, 214 | { 215 | "source": { 216 | "id": null, 217 | "name": "CleanTechnica" 218 | }, 219 | "author": "Frugal Moogal", 220 | "title": "Tesla [TSLA] FUD: Regulatory Credits", 221 | "description": "When I started writing for CleanTechnica, a big part of my goal was to examine the articles that seemed to spread FUD — fear, uncertainty, and doubt — about Tesla to determine when there was something to the underlying message and when I felt the data was bei…", 222 | "url": "https://cleantechnica.com/2021/02/03/tesla-tsla-fud-regulatory-credits/", 223 | "urlToImage": "https://cleantechnica.com/files/2019/09/Tesla-Model-3-S-X-Fleet-Red-White-Blue-Supercharging-Florida-National-Drive-Electric-Week-Metro.jpg", 224 | "publishedAt": "2021-02-04T01:19:13Z", 225 | "content": "CarsPublished on February 3rd, 2021 |\r\n by Frugal Moogal\r\n0\r\nFebruary 3rd, 2021 by Frugal Moogal \r\nWhen I started writing for CleanTechnica, a big part of my goal was to examine the articles that see… [+10283 chars]" 226 | }, 227 | { 228 | "source": { 229 | "id": null, 230 | "name": "Stuff.co.nz" 231 | }, 232 | "author": "DAMIEN O'CARROLL", 233 | "title": "Tesla US recall affects some NZ cars", 234 | "description": "Tesla's global recall of Model S and Model X vehicles built before March 2018 will include some local cars.", 235 | "url": "https://www.stuff.co.nz/motoring/124148841/tesla-us-recall-affects-some-nz-cars", 236 | "urlToImage": "https://resources.stuff.co.nz/content/dam/images/4/y/q/s/n/0/image.related.StuffLandscapeSixteenByNine.1420x800.21wxux.png/1612400663905.jpg", 237 | "publishedAt": "2021-02-04T01:04:23Z", 238 | "content": "A limited amount of Tesla vehicles in New Zealand are expected to be affected by the recall of around 135,000 vehicles because the large touch screens on the console can go dark.\r\n After initially re… [+2945 chars]" 239 | }, 240 | { 241 | "source": { 242 | "id": null, 243 | "name": "Itmedia.co.jp" 244 | }, 245 | "author": "産経新聞", 246 | "title": "勢いづく米Tesla “走るスマホ”で黒字転換 先行きに不安も", 247 | "description": "EV専業の米Teslaが創業以来初の黒字転換に成功した。米中対立の最中でも中国販売を伸ばすなど勢いづく。ただ近年の業績は本業以外での収益でかさ上げされている他、大規模リコールの実施や他社のEV強化も予想され、先行きには不安も残る。", 248 | "url": "https://www.itmedia.co.jp/news/articles/2102/04/news061.html", 249 | "urlToImage": "https://image.itmedia.co.jp/news/articles/2102/04/l_ecn2102030026view_1.jpg", 250 | "publishedAt": "2021-02-04T01:00:00Z", 251 | "content": "AppleGAFAITEVTeslaTeslaEVGAFA\r\n50\r\nTeslaCEO20201212720127210075017Tesla\r\nTesla20499647201EV\r\nOTATesla\r\n193320137000EV\r\nTesla11ZEVZEV\r\n11EVZEVTeslaZEV\r\nTesla20ZEV1580002.7165ZEV32\r\nTesla3135000\r\nGener… [+66 chars]" 252 | }, 253 | { 254 | "source": { 255 | "id": null, 256 | "name": "Jcnews.ru" 257 | }, 258 | "author": "Ксения Н", 259 | "title": "Российский рынок новых электромобилей в 2020 году вырос в 2 раза", 260 | "description": "По итогам 2020 года россияне в общей сложности приобрели 687 новых электромобилей, что на 95% больше, чем в 2019 году (353 шт.). Такие данные приводят эксперты агентства «АВТОСТАТ». Отметим, что лидирует в данном сегменте модель Nissan Leaf с результатом 144 …", 261 | "url": "http://www.jcnews.ru/news/rossiyskiy_ryinok_novyih_elektromobiley_v_2020_godu_vyiros_v_2_raza/54195", 262 | "urlToImage": null, 263 | "publishedAt": "2021-02-04T00:55:00Z", 264 | "content": "2020 687 , 95% , 2019 (353 .). «».\r\n, Nissan Leaf 144 ( 10% , 2019 ). – , Audi E-Tron, . : 134 2- . – Tesla Model 3 (110 .), 5 .\r\n– 100 : Porsche Taycan (91 .), Tesla Model X (81 .), Jaguar I-Pace (7… [+321 chars]" 265 | } 266 | ] 267 | } 268 | -------------------------------------------------------------------------------- /CleanArchitecture/Data/Network/RestAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RestAPI.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import Foundation 9 | import Alamofire 10 | import RxSwift 11 | 12 | struct ResponseError: Error { 13 | enum ErrorType { 14 | case af 15 | case json 16 | case unknown 17 | } 18 | 19 | let type: ErrorType 20 | let error: Error? 21 | let afError: AFError? 22 | 23 | var message: String { 24 | switch type { 25 | case .af: 26 | return afError?.localizedDescription ?? "" 27 | case .json: 28 | return error?.localizedDescription ?? "" 29 | default: 30 | return "Unknown error" 31 | } 32 | } 33 | } 34 | 35 | enum HTTPMethod: String { 36 | case get = "GET" 37 | case post = "POST" 38 | case put = "PUT" 39 | case delete = "DELETE" 40 | 41 | var afMethod: Alamofire.HTTPMethod { 42 | return Alamofire.HTTPMethod(rawValue: self.rawValue) 43 | } 44 | } 45 | 46 | protocol RestAPI { 47 | var path: String { get } 48 | var method: HTTPMethod { get } 49 | var headers: HTTPHeaders { get } 50 | var mockFile: String { get } 51 | 52 | func request(returnType: T.Type, paramType: K.Type, params: K, completion: @escaping (_ result: T?, _ error: ResponseError?) -> Void) 53 | 54 | func request(returnType: T.Type, completion: @escaping (_ result: T?, _ error: ResponseError?) -> Void) 55 | 56 | func request(returnType: T.Type, paramType: K.Type, params: K) -> Single 57 | 58 | func request(returnType: T.Type) -> Single 59 | } 60 | 61 | extension RestAPI { 62 | var headers: HTTPHeaders { 63 | return [ 64 | "Content-Type": "application/json" 65 | ] 66 | } 67 | 68 | var mockFile: String { 69 | return "" 70 | } 71 | 72 | func request(returnType: T.Type, paramType: K.Type, params: K, completion: @escaping (_ result: T?, _ error: ResponseError?) -> Void) { 73 | if handleMock(returnType: returnType, completion: completion) { 74 | return 75 | } 76 | 77 | let url = "\(Config.current.baseUrl)/\(path)" 78 | 79 | print(">>> Network Request \(method.rawValue):", url) 80 | 81 | AF.request(url, 82 | method: method.afMethod, 83 | parameters: params, 84 | encoder: JSONParameterEncoder.default, 85 | headers: headers) 86 | .validate(statusCode: 200 ..< 300) 87 | .responseDecodable(of: T.self) { response in 88 | guard let value = response.value else { 89 | let error = ResponseError(type: .af, error: nil, afError: response.error) 90 | completion(nil, error) 91 | return 92 | } 93 | completion(value, nil) 94 | } 95 | } 96 | 97 | func request(returnType: T.Type, completion: @escaping (_ result: T?, _ error: ResponseError?) -> Void) { 98 | if handleMock(returnType: returnType, completion: completion) { 99 | return 100 | } 101 | 102 | let url = "\(Config.current.baseUrl)/\(path)" 103 | 104 | print(">>> Network Request \(method.rawValue):", url) 105 | 106 | AF.request(url, 107 | method: method.afMethod, 108 | headers: headers) 109 | .validate(statusCode: 200 ..< 300) 110 | .responseDecodable(of: T.self) { response in 111 | guard let value = response.value else { 112 | let error = ResponseError(type: .af, error: nil, afError: response.error) 113 | completion(nil, error) 114 | return 115 | } 116 | completion(value, nil) 117 | } 118 | } 119 | 120 | func request(returnType: T.Type, paramType: K.Type, params: K) -> Single { 121 | return Single.create { single in 122 | request(returnType: returnType, paramType: paramType, params: params) { (result, error) in 123 | guard let result = result else { 124 | single(.failure(error!)) 125 | return 126 | } 127 | single(.success(result)) 128 | } 129 | return Disposables.create() 130 | } 131 | } 132 | 133 | func request(returnType: T.Type) -> Single { 134 | return Single.create { single in 135 | request(returnType: returnType) { (result, error) in 136 | guard let result = result else { 137 | single(.failure(error!)) 138 | return 139 | } 140 | single(.success(result)) 141 | } 142 | return Disposables.create() 143 | } 144 | } 145 | 146 | private func handleMock(returnType: T.Type, completion: @escaping (_ result: T?, _ error: ResponseError?) -> Void) -> Bool { 147 | if Config.current.mockEnabled, 148 | !mockFile.isEmpty, 149 | let path = Bundle.main.path(forResource: mockFile, ofType: ""), 150 | let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { 151 | let decoder = JSONDecoder() 152 | do { 153 | let value = try decoder.decode(returnType, from: data) 154 | completion(value, nil) 155 | } catch { 156 | let error = ResponseError(type: .json, error: error, afError: nil) 157 | completion(nil, error) 158 | } 159 | 160 | return true 161 | } 162 | 163 | return false 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /CleanArchitecture/Data/Repositories/ArticleRepository.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewsRepository.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import RxSwift 9 | 10 | struct SearchArticleResult: Decodable { 11 | @Default.EmptyList var articles: [Article] 12 | @Default.Zero var totalResults: Int 13 | } 14 | 15 | struct ArticleRepository: ArticleUseCase { 16 | func findArticlesByKeyword(_ keyword: String, pageSize: Int, page: Int) -> Single<[Article]> { 17 | return ArticleService 18 | .searchArticlesByKeyword(q: keyword, pageSize: pageSize, page: page) 19 | .request(returnType: SearchArticleResult.self) 20 | .map { $0.articles } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CleanArchitecture/Domain/Entities/Article.swift: -------------------------------------------------------------------------------- 1 | // 2 | // News.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Article: Decodable { 11 | @Default.Empty var author: String 12 | @Default.Empty var title: String 13 | @Default.Empty var description: String 14 | @Default.Empty var url: String 15 | @Default.Empty var urlToImage: String 16 | @Default.Empty var publishedAt: String 17 | @Default.Empty var content: String 18 | } 19 | 20 | extension Article { 21 | var formattedPublishedAt: String { 22 | let formatter = DateFormatter() 23 | formatter.dateFormat = "YYYY-MM-DD'T'HH:mm:ssZ" 24 | guard let date = formatter.date(from: publishedAt) else { return "" } 25 | 26 | let outFormatter = DateFormatter() 27 | outFormatter.dateFormat = "MMM dd, HH:mm" 28 | return "\(NSLocalizedString("Updated", comment: "")): \(outFormatter.string(from: date))" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /CleanArchitecture/Domain/Entities/CodableDefault.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Default.swift 3 | // CleanArchitecture 4 | // 5 | // Created by Quan on 2/22/21. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol DefaultSource { 11 | associatedtype Value: Decodable 12 | static var defaultValue: Value { get } 13 | } 14 | 15 | enum Default {} 16 | 17 | extension Default { 18 | @propertyWrapper 19 | struct Wrapper { 20 | typealias Value = Source.Value 21 | var wrappedValue = Source.defaultValue 22 | } 23 | } 24 | 25 | extension Default.Wrapper: Decodable { 26 | init(from decoder: Decoder) throws { 27 | let container = try decoder.singleValueContainer() 28 | wrappedValue = try container.decode(Value.self) 29 | } 30 | } 31 | 32 | extension KeyedDecodingContainer { 33 | func decode(_ type: Default.Wrapper.Type, 34 | forKey key: Key) throws -> Default.Wrapper { 35 | try decodeIfPresent(type, forKey: key) ?? .init() 36 | } 37 | } 38 | 39 | extension Default { 40 | typealias Source = DefaultSource 41 | typealias List = Decodable & ExpressibleByArrayLiteral 42 | typealias Map = Decodable & ExpressibleByDictionaryLiteral 43 | 44 | enum Sources { 45 | enum True: Source { 46 | static var defaultValue: Bool { true } 47 | } 48 | 49 | enum False: Source { 50 | static var defaultValue: Bool { false } 51 | } 52 | 53 | enum Zero: Source { 54 | static var defaultValue: Int { 0 } 55 | } 56 | 57 | enum ZeroDouble: Source { 58 | static var defaultValue: Double { 0.0 } 59 | } 60 | 61 | enum Empty: Source { 62 | static var defaultValue: String { "" } 63 | } 64 | 65 | enum EmptyList: Source { 66 | static var defaultValue: T { [] } 67 | } 68 | 69 | enum EmptyMap: Source { 70 | static var defaultValue: T { [:] } 71 | } 72 | } 73 | } 74 | 75 | extension Default { 76 | typealias True = Wrapper 77 | typealias False = Wrapper 78 | typealias Zero = Wrapper 79 | typealias ZeroDouble = Wrapper 80 | typealias Empty = Wrapper 81 | typealias EmptyList = Wrapper> 82 | typealias EmptyMap = Wrapper> 83 | } 84 | 85 | extension Default.Wrapper: Equatable where Value: Equatable {} 86 | extension Default.Wrapper: Hashable where Value: Hashable {} 87 | 88 | extension Default.Wrapper: Encodable where Value: Encodable { 89 | func encode(to encoder: Encoder) throws { 90 | var container = encoder.singleValueContainer() 91 | try container.encode(wrappedValue) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /CleanArchitecture/Domain/UseCases/ArticleUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewsUseCase.swift 3 | // NewsApp 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import RxSwift 9 | 10 | protocol ArticleUseCase { 11 | func findArticlesByKeyword(_ keyword: String, pageSize: Int, page: Int) -> Single<[Article]> 12 | } 13 | -------------------------------------------------------------------------------- /CleanArchitecture/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 | LSRequiresIPhoneOS 22 | 23 | NSAppTransportSecurity 24 | 25 | NSAllowsArbitraryLoads 26 | 27 | 28 | UIApplicationSupportsIndirectInputEvents 29 | 30 | UILaunchStoryboardName 31 | LaunchScreen 32 | UIMainStoryboardFile 33 | Main 34 | UIRequiredDeviceCapabilities 35 | 36 | armv7 37 | 38 | UISupportedInterfaceOrientations 39 | 40 | UIInterfaceOrientationPortrait 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | UISupportedInterfaceOrientations~ipad 45 | 46 | UIInterfaceOrientationPortrait 47 | UIInterfaceOrientationPortraitUpsideDown 48 | UIInterfaceOrientationLandscapeLeft 49 | UIInterfaceOrientationLandscapeRight 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /CleanArchitectureTests/Helpers/MockLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockLoader.swift 3 | // NewsAppTests 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import Foundation 9 | import RxSwift 10 | 11 | struct MockLoader { 12 | static func load(returnType: T.Type, file: String) -> Single { 13 | return Single.create { single in 14 | if let path = Bundle.main.path(forResource: file, ofType: ""), 15 | let data = try? Data(contentsOf: URL(fileURLWithPath: path)) { 16 | let decoder = JSONDecoder() 17 | do { 18 | let value = try decoder.decode(returnType, from: data) 19 | single(.success(value)) 20 | } catch { 21 | single(.failure(error)) 22 | } 23 | } 24 | return Disposables.create() 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /CleanArchitectureTests/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 | -------------------------------------------------------------------------------- /CleanArchitectureTests/RepositoryMocks/ArticleRepositoryMock.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArticleRepositoryMock.swift 3 | // NewsAppTests 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import Foundation 9 | import RxSwift 10 | @testable import CleanArchitecture 11 | 12 | struct ArticleRepositoryMock: ArticleUseCase { 13 | func findArticlesByKeyword(_ keyword: String, pageSize: Int, page: Int) -> Single<[Article]> { 14 | return MockLoader 15 | .load(returnType: SearchArticleResult.self, file: "searchArticles.json") 16 | .map { $0.articles } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CleanArchitectureTests/RepositoryMocks/Mock/searchArticles.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "status": "ok", 4 | "totalResults": 18346, 5 | "articles": [ 6 | { 7 | "source": { 8 | "id": null, 9 | "name": "Cnbeta.com" 10 | }, 11 | "author": null, 12 | "title": "特斯拉砍掉了Model Y的长续航后驱版本", 13 | "description": "Teslarati 周二报道称,特斯拉已经砍掉了 Model Y 的长续航后驱版本,目前销售人员正在向预订客户通报此事,并要求变更为其它配置。起初,马斯克宣称 Model Y 标准后驱版的续航里程太低,因而决定用长续航后驱版来弥补。然而上个月的时候,该公司又透露了 Model Y 标准续航 / 后驱版本的存在,并于近日开启了销售。\r\n资料图(来自:Tesla)\r\n尽管特斯拉习惯了悄悄改变在售产品的阵容,但对于 Model Y 标准续航版的摇摆不定,该公司公关部门尚未回应外媒的置评请求。\r\n目前官网上可选的 Mod…", 14 | "url": "http://www.cnbeta.com/articles/tech/1086657.htm", 15 | "urlToImage": "https://static.cnbetacdn.com/article/2021/0204/9b6e5de767acb78.jpg", 16 | "publishedAt": "2021-02-04T02:37:06Z", 17 | "content": "Teslarati Model Y Model Y Model Y /" 18 | }, 19 | { 20 | "source": { 21 | "id": null, 22 | "name": "MarketWatch" 23 | }, 24 | "author": "Mike Murphy", 25 | "title": "Don’t buy a Tesla during a production ramp, Elon Musk warns", 26 | "description": "In a new interview, Elon Musk agreed with some scathing criticism of Telsa Inc. vehicles, and advised against buying Tesla vehicles during a production ramp-up.", 27 | "url": "https://www.marketwatch.com/story/dont-buy-a-tesla-during-a-production-ramp-elon-musk-warns-11612406222", 28 | "urlToImage": "https://images.mktw.net/im-294744/social", 29 | "publishedAt": "2021-02-04T02:37:00Z", 30 | "content": "In a new interview, Elon Musk agreed with some scathing criticism of Telsa Inc. vehicles, and advised against buying Tesla vehicles during a production ramp-up.The extraordinarily candid interview ev… [+2308 chars]" 31 | }, 32 | { 33 | "source": { 34 | "id": null, 35 | "name": "New York Post" 36 | }, 37 | "author": "Elizabeth Elizalde", 38 | "title": "Elon Musk says Neuralink could start implanting chips in human brains ‘later this year’", 39 | "description": "Elon Musk says that his neurotechnology company Neuralink could be launching human trials “later this year.” Musk shared the news in response to a Twitter user who said he’d be willing to participate in the human trials, which would implant an artificial inte…", 40 | "url": "https://nypost.com/2021/02/03/elon-musk-neuralink-could-start-implanting-brain-chips-later-this-year/", 41 | "urlToImage": "https://nypost.com/wp-content/uploads/sites/2/2021/02/Elon-Musk.jpg?quality=90&strip=all&w=1200", 42 | "publishedAt": "2021-02-04T02:33:40Z", 43 | "content": "Elon Musk says that his neurotechnology company Neuralink could be launching human trials “later this year.” \r\nMusk shared the news in response to a Twitter user who said he’d be willing to participa… [+1314 chars]" 44 | }, 45 | { 46 | "source": { 47 | "id": null, 48 | "name": "CleanTechnica" 49 | }, 50 | "author": "Scott Cooney", 51 | "title": "Energy Efficiency Before Going Solar: How Much Difference In The Cost Does It Make?", 52 | "description": "Efficiency combined with solar changes the equation dramatically. The difference for me was roughly 2/3 the cost of a new Tesla Model 3.", 53 | "url": "https://cleantechnica.com/2021/02/03/efficiency-before-solar-how-much-cost-difference-will-it-make/", 54 | "urlToImage": "https://cleantechnica.com/files/2020/09/tesla-solar-roof-energy-home-residential-BIPV-tiles-solarglass-CHUCK-KYLE-construction-installation-10-scaled.jpg", 55 | "publishedAt": "2021-02-04T02:30:48Z", 56 | "content": "BuildingsPublished on February 3rd, 2021 |\r\n by Scott Cooney\r\n0\r\nFebruary 3rd, 2021 by Scott Cooney \r\nRooftop or on-site solar can often generate enough power for homes, businesses, and industry, hel… [+4867 chars]" 57 | }, 58 | { 59 | "source": { 60 | "id": null, 61 | "name": "Space Daily" 62 | }, 63 | "author": null, 64 | "title": "Tech billionaire Elon Musk says he's off Twitter 'for a while'", 65 | "description": "New York (AFP) Feb 2, 2021\n\n\n Tech billionaire Elon Musk said Tuesday he was taking a break from Twitter \"for a while\", after his posts on the platform helped fuel a stock market frenzy that sent the share prices of several companies soaring. \n\nMusk overtook …", 66 | "url": "https://www.spacedaily.com/reports/Tech_billionaire_Elon_Musk_says_hes_off_Twitter_for_a_while_999.html", 67 | "urlToImage": "https://www.spxdaily.com/images-hg/twitter-bomb-hg.jpg", 68 | "publishedAt": "2021-02-04T02:23:15Z", 69 | "content": "Tech billionaire Elon Musk said Tuesday he was taking a break from Twitter \"for a while\", after his posts on the platform helped fuel a stock market frenzy that sent the share prices of several compa… [+2788 chars]" 70 | }, 71 | { 72 | "source": { 73 | "id": null, 74 | "name": "Space Daily" 75 | }, 76 | "author": null, 77 | "title": "Amazon's Bezos, latest tycoon to pursue his 'passion'", 78 | "description": "Washington (AFP) Feb 3, 2021\n\n\n Bill Gates set out to heal the world. His Microsoft co-founder Paul Allen bought sports teams. Ted Turner raced yachts. And Donald Trump went into politics. \n\nAmazon founder Jeff Bezos, the world's richest man, plans to build r…", 79 | "url": "https://www.spacedaily.com/reports/Amazons_Bezos_latest_tycoon_to_pursue_his_passion_999.html", 80 | "urlToImage": "https://www.spxdaily.com/images-hg/jeff-bezos-new-shepard-hg.jpg", 81 | "publishedAt": "2021-02-04T02:23:15Z", 82 | "content": "Bill Gates set out to heal the world. His Microsoft co-founder Paul Allen bought sports teams. Ted Turner raced yachts. And Donald Trump went into politics.\r\nAmazon founder Jeff Bezos, the world's ri… [+4947 chars]" 83 | }, 84 | { 85 | "source": { 86 | "id": null, 87 | "name": "Cool3c.com" 88 | }, 89 | "author": "中央社", 90 | "title": "未來連解鎖iPhone都不用就可以直接開門啟動特斯拉電動車", 91 | "description": "UWB技術在iPhone 11的時候發表,可以透過UWB確定附近是否還有其他iPhone,現在特斯拉透過這個技術讓iPhone成為更完整的數位鑰匙,把手機放在身上直接啟動特斯拉非常方便。 電動車大廠特斯拉(Tesla)新車款設計傳曝光,外媒報導特斯拉規劃採用UWB技術,可讓iPhone變身「數位鑰匙」,更精確定位電動車位置,車主不用掏出iPhone就可無線開鎖,使用上比既有App軟體更具安全性。 國外科技網站The Verge報導,提交給美國聯邦傳播委員會(Federal Communications Commi…", 92 | "url": "https://www.cool3c.com/article/159688", 93 | "urlToImage": "https://sw.cool3c.com/user/6/2021/4a22566c-f943-4cf4-9ccf-2ca42beb785c.jpg?fit=max&w=1400&q=80", 94 | "publishedAt": "2021-02-04T02:23:11Z", 95 | "content": "UWBiPhone 11UWBiPhoneiPhone\r\nTeslaUWBiPhoneiPhoneApp\r\nThe VergeFederal Communications CommissionUWBUltra Wide Band\r\n63UWB\r\nUWBiPhoneSamsungUWB\r\niPhoneUWBiPhoneiPhoneUWB\r\nMacRumorsU1iPhone 115GiPhone … [+118 chars]" 96 | }, 97 | { 98 | "source": { 99 | "id": "rt", 100 | "name": "RT" 101 | }, 102 | "author": "RT en Español", 103 | "title": "Elon Musk asegura que las pruebas de implantes cerebrales de Neuralink en humanos podrían iniciar este año", 104 | "description": "El director ejecutivo de Tesla señaló que uno de los objetivos del proyecto es resolver las lesiones cerebrales y de columna vertebral.", 105 | "url": "https://actualidad.rt.com/actualidad/382417-elon-musk-asegura-pruebas-humanos-implantes-cerebrales-iniciar-finales-ano", 106 | "urlToImage": "https://cdni.rt.com/actualidad/public_images/2021.02/article/601aef86e9ff716fa040ff98.JPG", 107 | "publishedAt": "2021-02-04T02:21:43Z", 108 | "content": "El director ejecutivo de Tesla y SpaceX, Elon Musk, reveló este martes que la compañía de neurotecnología Neuralink podría empezar a implantar chips de computadora en humanos a finales de este año, c… [+1359 chars]" 109 | }, 110 | { 111 | "source": { 112 | "id": null, 113 | "name": "CleanTechnica" 114 | }, 115 | "author": "Jo Borrás", 116 | "title": "Lordstown Motors Electric Pickup Will Use “Tesla Batteries” From LG", 117 | "description": "Lordstown Motors' electric pickup truck will use the same \"Tesla Batteries\" from LG Chem found in the Model 3 and Model Y, and it's making an RV, too!", 118 | "url": "https://cleantechnica.com/2021/02/03/lordstown-motors-electric-pickup-will-use-tesla-batteries-from-lg/", 119 | "urlToImage": "https://cleantechnica.com/files/2021/02/1612389422978blob-e1612391672954.png", 120 | "publishedAt": "2021-02-04T02:20:51Z", 121 | "content": "BatteriesPublished on February 3rd, 2021 |\r\n by Jo Borrás\r\n0\r\nFebruary 3rd, 2021 by Jo Borrás \r\nLordstown Motors recently revealed a new round of commercial, operational, and strategic development up… [+3415 chars]" 122 | }, 123 | { 124 | "source": { 125 | "id": null, 126 | "name": "Cnbeta.com" 127 | }, 128 | "author": null, 129 | "title": "马斯克说了实话:别在生产旺季购买特斯拉,品控差一些", 130 | "description": "2月4日,美国电动汽车制造商特斯拉首席执行官埃隆·马斯克(Elon Musk)在当地时间周二接受采访时表示,不要在生产旺季购买特斯拉汽车。他还说,要么一开始就买,要么等产量稳定下来以后再买。马斯克接受采访时承认,一旦公司大幅扩大产能,特斯拉电动汽车的质量就会受到影响。马斯克还表示,特斯拉在接近2020年底时生产汽车的速度太快了,以至于汽车漆面出现了不同程度的问题。\r\n视频截图\r\n特斯拉因汽车质量问题受到了很多批评,Model 3和Model Y开始提高产量后更是如此。但在接受行业分析师桑迪·门罗(Sandy Mu…", 131 | "url": "http://www.cnbeta.com/articles/tech/1086635.htm", 132 | "urlToImage": "https://static.cnbetacdn.com/article/2021/0204/b5dc21ef11bf942.jpg", 133 | "publishedAt": "2021-02-04T02:13:38Z", 134 | "content": "[]Galaxy S21 UltraiPhone 12 Pro Max \r\n2021-02-04 220" 135 | }, 136 | { 137 | "source": { 138 | "id": null, 139 | "name": "Cnbeta.com" 140 | }, 141 | "author": "raymon725", 142 | "title": "马斯克说了实话:别在生产旺季购买特斯拉,品控差一些", 143 | "description": "2月4日,美国电动汽车制造商特斯拉首席执行官埃隆·马斯克(Elon Musk)在当地时间周二接受采访时表示,不要在生产旺季购买特斯拉汽车。他还说,要么一开始就买,要么等产量稳定下来以后再买。马斯克接受采访时承认,一旦公司大幅扩大产能,特斯拉电动汽车的质量就会受到影响。马斯克还表示,特斯拉在接近2020年底时生产汽车的速度太快了,以至于汽车漆面出现了不同程度的问题。 阅读全文", 144 | "url": "https://www.cnbeta.com/articles/tech/1086635.htm", 145 | "urlToImage": "https://static.cnbetacdn.com/article/2021/0204/b5dc21ef11bf942.jpg", 146 | "publishedAt": "2021-02-04T02:13:38Z", 147 | "content": "Model 3Model Y·(Sandy Munro)\r\n:?:\r\nTesla \r\nModel 312\r\nModel 3202050\r\nElon Musk Interview 1on1 with Sandy Munrovia\r\n2018Model 390:\r\nModel YAutopilot" 148 | }, 149 | { 150 | "source": { 151 | "id": null, 152 | "name": "Hotnews.ro" 153 | }, 154 | "author": "https://www.facebook.com/www.hotnews.ro", 155 | "title": "​Business report: Șoferii își vor putea cumpăra RCA-ul direct de la stat. Cele mai dulci roșii din lume. De ce nordicii au mai mulţi copii decât cei din sud. Somatii pentru debite deja achitate, trimise pe banda rulanta", 156 | "description": "Firmele care nu respectă regulile stabilite în contextul pandemiei ar putea fi suspendate ● ​De ce europenii din nord au mai mulţi copii decât cei din sud ● Somatii pentru debite deja achitate, trimise pe banda rulanta ● România, țara care importă deșeuri făr…", 157 | "url": "https://economie.hotnews.ro/stiri-finante_banci-24582586-business-report-soferii-isi-vor-putea-cumpara-rca-direct-stat-nordicii-mai-multi-copii-decat-cei-din-sud-somatii-pentru-debite-deja-achitate-trimise-banda-rulanta-cele-mai-dulci-rosii-din-lume.htm", 158 | "urlToImage": "http://media.hotnews.ro/media_server1/image-2020-03-20-23737457-70-business-report.jpg", 159 | "publishedAt": "2021-02-04T02:13:00Z", 160 | "content": "Firmele care nu respect regulile stabilite în contextul pandemiei ar putea fi suspendate De ce europenii din nord au mai muli copii decât cei din sud Somatii pentru debite deja achitate, trimise pe b… [+20640 chars]" 161 | }, 162 | { 163 | "source": { 164 | "id": null, 165 | "name": "Observador.pt" 166 | }, 167 | "author": "Alfredo Lavrador", 168 | "title": "As boas opções (e as menos boas) dos Tesla Model S e X", 169 | "description": "O novo Model S trouxe consigo uma série de novidades mas, como é habitual na Tesla, umas foram aplaudidas enquanto outras causaram alguma polémica. Veja aqui as vantagens e desvantagens de cada uma.", 170 | "url": "https://observador.pt/2021/02/04/as-boas-opcoes-e-as-menos-boas-do-tesla-model-s/", 171 | "urlToImage": "https://wm.observador.pt/wm/obs/l/https%3A%2F%2Fbordalo.observador.pt%2Fv2%2Frs%3Afill%3A770%3A403%2Fc%3A1920%3A1079%3Anowe%3A0%3A0%2Fq%3A85%2Fplain%2Fhttps%3A%2F%2Fs3.observador.pt%2Fwp-content%2Fuploads%2F2021%2F02%2F04011507%2F2021-tesla-model-s-plaid.jpg", 172 | "publishedAt": "2021-02-04T02:00:48Z", 173 | "content": "Se os clientes da marca esperavam um restyling do Model S (e do Model X), a Tesla surpreendeu-os com uma mão cheia de novidades, mais do que poderiam ambicionar. Mas se a maioria destes trunfos ofere… [+7730 chars]" 174 | }, 175 | { 176 | "source": { 177 | "id": null, 178 | "name": "Gizmodo Australia" 179 | }, 180 | "author": "Jason Torchinsky", 181 | "title": "Time To Talk About That Bonkers Beetle/Subaru Mash-Up That’s Been Around Forever", 182 | "description": "You know how there’s some remarkable cars that just sort of perpetually bounce around the collective consciousness of gearheads, periodically re-emerging to once again dazzle/delight/disgust a new crop of car-fetishists? Of course you do. One of the most endu…", 183 | "url": "https://www.gizmodo.com.au/2021/02/time-to-talk-about-that-bonkers-beetle-subaru-mash-up-thats-been-around-forever/", 184 | "urlToImage": "https://imgix.gizmodo.com.au/content/uploads/sites/2/2021/02/03/lwmo4atqjhujhkd1um13.png?ar=16%3A9&auto=format&fit=crop&q=65&w=1280", 185 | "publishedAt": "2021-02-04T02:00:45Z", 186 | "content": "You know how theres some remarkable cars that just sort of perpetually bounce around the collective consciousness of gearheads, periodically re-emerging to once again dazzle/delight/disgust a new cro… [+4452 chars]" 187 | }, 188 | { 189 | "source": { 190 | "id": null, 191 | "name": "Newsmth.net" 192 | }, 193 | "author": "KevinYuan158", 194 | "title": "[Go编程语言] 【特斯拉】-golang-北京/上海-42K-84K", 195 | "description": "发信人: KevinYuan158 (KevinYuan158), 信区: Golang标 题: 【特斯拉】-golang-北京/上海-42K-84K发信站: 水木社区 (Mon Jan 25 10:44:14 2021), 站内Senior Software EngineerRoleTesla is looking for a strong Software Engineer to design and develop software for its current and next generations …", 196 | "url": "http://www.newsmth.net/bbstcon.php?board=Golang&gid=4318", 197 | "urlToImage": null, 198 | "publishedAt": "2021-02-04T01:37:02Z", 199 | "content": null 200 | }, 201 | { 202 | "source": { 203 | "id": null, 204 | "name": "Finance.ua" 205 | }, 206 | "author": "autonews.ua", 207 | "title": "Tesla отзывает 135 000 электромобилей", 208 | "description": "Компания Tesla отзывает в США 135 000 электромобилей из-за сбоя в программном обеспечении. Будут ли эти машины отозваны в других странах – пока не известно.", 209 | "url": "https://news.finance.ua/ru/news/-/486776/tesla-otzyvaet-135-000-elektromobilej", 210 | "urlToImage": "http://resources.finance.ua/ru/news/image-repost?id=486776", 211 | "publishedAt": "2021-02-04T01:36:00Z", 212 | "content": "Tesla 135 000 - . .\r\n , Tesla Model S 2012-2018 Model X 2016-2018 .\r\n - , , . - , .\r\n - , . .\r\n , , . , ." 213 | }, 214 | { 215 | "source": { 216 | "id": null, 217 | "name": "CleanTechnica" 218 | }, 219 | "author": "Frugal Moogal", 220 | "title": "Tesla [TSLA] FUD: Regulatory Credits", 221 | "description": "When I started writing for CleanTechnica, a big part of my goal was to examine the articles that seemed to spread FUD — fear, uncertainty, and doubt — about Tesla to determine when there was something to the underlying message and when I felt the data was bei…", 222 | "url": "https://cleantechnica.com/2021/02/03/tesla-tsla-fud-regulatory-credits/", 223 | "urlToImage": "https://cleantechnica.com/files/2019/09/Tesla-Model-3-S-X-Fleet-Red-White-Blue-Supercharging-Florida-National-Drive-Electric-Week-Metro.jpg", 224 | "publishedAt": "2021-02-04T01:19:13Z", 225 | "content": "CarsPublished on February 3rd, 2021 |\r\n by Frugal Moogal\r\n0\r\nFebruary 3rd, 2021 by Frugal Moogal \r\nWhen I started writing for CleanTechnica, a big part of my goal was to examine the articles that see… [+10283 chars]" 226 | }, 227 | { 228 | "source": { 229 | "id": null, 230 | "name": "Stuff.co.nz" 231 | }, 232 | "author": "DAMIEN O'CARROLL", 233 | "title": "Tesla US recall affects some NZ cars", 234 | "description": "Tesla's global recall of Model S and Model X vehicles built before March 2018 will include some local cars.", 235 | "url": "https://www.stuff.co.nz/motoring/124148841/tesla-us-recall-affects-some-nz-cars", 236 | "urlToImage": "https://resources.stuff.co.nz/content/dam/images/4/y/q/s/n/0/image.related.StuffLandscapeSixteenByNine.1420x800.21wxux.png/1612400663905.jpg", 237 | "publishedAt": "2021-02-04T01:04:23Z", 238 | "content": "A limited amount of Tesla vehicles in New Zealand are expected to be affected by the recall of around 135,000 vehicles because the large touch screens on the console can go dark.\r\n After initially re… [+2945 chars]" 239 | }, 240 | { 241 | "source": { 242 | "id": null, 243 | "name": "Itmedia.co.jp" 244 | }, 245 | "author": "産経新聞", 246 | "title": "勢いづく米Tesla “走るスマホ”で黒字転換 先行きに不安も", 247 | "description": "EV専業の米Teslaが創業以来初の黒字転換に成功した。米中対立の最中でも中国販売を伸ばすなど勢いづく。ただ近年の業績は本業以外での収益でかさ上げされている他、大規模リコールの実施や他社のEV強化も予想され、先行きには不安も残る。", 248 | "url": "https://www.itmedia.co.jp/news/articles/2102/04/news061.html", 249 | "urlToImage": "https://image.itmedia.co.jp/news/articles/2102/04/l_ecn2102030026view_1.jpg", 250 | "publishedAt": "2021-02-04T01:00:00Z", 251 | "content": "AppleGAFAITEVTeslaTeslaEVGAFA\r\n50\r\nTeslaCEO20201212720127210075017Tesla\r\nTesla20499647201EV\r\nOTATesla\r\n193320137000EV\r\nTesla11ZEVZEV\r\n11EVZEVTeslaZEV\r\nTesla20ZEV1580002.7165ZEV32\r\nTesla3135000\r\nGener… [+66 chars]" 252 | }, 253 | { 254 | "source": { 255 | "id": null, 256 | "name": "Jcnews.ru" 257 | }, 258 | "author": "Ксения Н", 259 | "title": "Российский рынок новых электромобилей в 2020 году вырос в 2 раза", 260 | "description": "По итогам 2020 года россияне в общей сложности приобрели 687 новых электромобилей, что на 95% больше, чем в 2019 году (353 шт.). Такие данные приводят эксперты агентства «АВТОСТАТ». Отметим, что лидирует в данном сегменте модель Nissan Leaf с результатом 144 …", 261 | "url": "http://www.jcnews.ru/news/rossiyskiy_ryinok_novyih_elektromobiley_v_2020_godu_vyiros_v_2_raza/54195", 262 | "urlToImage": null, 263 | "publishedAt": "2021-02-04T00:55:00Z", 264 | "content": "2020 687 , 95% , 2019 (353 .). «».\r\n, Nissan Leaf 144 ( 10% , 2019 ). – , Audi E-Tron, . : 134 2- . – Tesla Model 3 (110 .), 5 .\r\n– 100 : Porsche Taycan (91 .), Tesla Model X (81 .), Jaguar I-Pace (7… [+321 chars]" 265 | } 266 | ] 267 | } 268 | -------------------------------------------------------------------------------- /CleanArchitectureTests/ViewModelTests/ArticleListViewModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArticleListViewModelTests.swift 3 | // NewsAppTests 4 | // 5 | // Created by Dinh Quan on 2/4/21. 6 | // 7 | 8 | import XCTest 9 | import RxTest 10 | import RxBlocking 11 | import RxSwift 12 | import struct RxCocoa.Driver 13 | @testable import CleanArchitecture 14 | 15 | typealias ViewModel = ArticleListViewModel 16 | 17 | class ArticleListViewModelTests: XCTestCase { 18 | 19 | var testScheduler: TestScheduler! 20 | var viewModel: ViewModel! 21 | let bag = DisposeBag() 22 | 23 | override func setUpWithError() throws { 24 | testScheduler = TestScheduler(initialClock: 0) 25 | viewModel = ArticleListViewModel() 26 | viewModel.articleUseCase = ArticleRepositoryMock() 27 | } 28 | 29 | override func tearDownWithError() throws {} 30 | 31 | func test_searchWithKeyword() throws { 32 | // Given 33 | let search = testScheduler 34 | .createHotObservable([ 35 | .next(0, "Tesla") 36 | ]) 37 | .asObservable() 38 | let input = ViewModel.Input(search: search, loadMore: .never()) 39 | let output = viewModel.transform(input: input) 40 | 41 | // When 42 | testScheduler.start() 43 | let articles = try! output.tableData.articles.toBlocking().first()! 44 | 45 | // Then 46 | XCTAssertEqual(articles.count, 20) 47 | XCTAssertEqual(articles[1].author, "Mike Murphy") 48 | } 49 | 50 | func test_loadMore() throws { 51 | // Given 52 | let search = testScheduler 53 | .createHotObservable([ 54 | .next(0, "Tesla") 55 | ]) 56 | .asObservable() 57 | let loadMore = testScheduler 58 | .createHotObservable([ 59 | .next(2, ()) 60 | ]) 61 | .asObservable() 62 | let input = ViewModel.Input(search: search, loadMore: loadMore) 63 | let output = viewModel.transform(input: input) 64 | 65 | // When 66 | testScheduler.start() 67 | let articles = try! output.tableData.articles.toBlocking().first()! 68 | 69 | // Then 70 | XCTAssertEqual(articles.count, 40) 71 | } 72 | 73 | func test_loadMoreWithError() throws { 74 | // Given 75 | let search = testScheduler 76 | .createHotObservable([ 77 | .next(0, "Tesla") 78 | ]) 79 | .asObservable() 80 | let loadMore = testScheduler 81 | .createHotObservable([ 82 | .next(1, ()), 83 | .next(2, ()), 84 | .next(3, ()), 85 | .next(4, ()) 86 | ]) 87 | .asObservable() 88 | let input = ViewModel.Input(search: search, loadMore: loadMore) 89 | let output = viewModel.transform(input: input) 90 | 91 | // When 92 | output.error 93 | .drive() 94 | .disposed(by: bag) 95 | 96 | testScheduler.start() 97 | let error = try! output.error.toBlocking().first() 98 | 99 | // Then 100 | XCTAssertNotNil(error) 101 | } 102 | } 103 | 104 | private extension Driver where Element == [ViewModel.SectionModel] { 105 | var articles: Observable<[Article]> { 106 | return map { sectionModels -> [Article] in 107 | return sectionModels[0].items 108 | .map { sectionItem -> Article in 109 | switch sectionItem { 110 | case .article(let vm): 111 | return vm.article 112 | } 113 | } 114 | } 115 | .asObservable() 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /CleanArchitectureUITests/CleanArchitectureUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanArchitectureUITests.swift 3 | // CleanArchitectureUITests 4 | // 5 | // Created by Dinh Quan on 2/21/21. 6 | // 7 | 8 | import XCTest 9 | 10 | class CleanArchitectureUITests: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | // Put setup code here. This method is called before the invocation of each test method in the class. 14 | 15 | // In UI tests it is usually best to stop immediately when a failure occurs. 16 | continueAfterFailure = false 17 | 18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 19 | } 20 | 21 | override func tearDownWithError() throws { 22 | // Put teardown code here. This method is called after the invocation of each test method in the class. 23 | } 24 | 25 | func testExample() throws { 26 | // UI tests must launch the application that they test. 27 | let app = XCUIApplication() 28 | app.launch() 29 | 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | func testLaunchPerformance() throws { 35 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 36 | // This measures how long it takes to launch your application. 37 | measure(metrics: [XCTApplicationLaunchMetric()]) { 38 | XCUIApplication().launch() 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CleanArchitectureUITests/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 | -------------------------------------------------------------------------------- /Images/DetailLevel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dinhquan/iOSCleanArchitecture/c073a5bc949e3870e00d40fc5aa4166704269a0c/Images/DetailLevel.png -------------------------------------------------------------------------------- /Images/HighLevel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dinhquan/iOSCleanArchitecture/c073a5bc949e3870e00d40fc5aa4166704269a0c/Images/HighLevel.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Quan Nguyen 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iOSCleanArchitecture 2 | iOS Clean Architecture with UIKit, MVVM, RxSwift 3 | 4 | ## High level overview 5 | 6 | ![alt text](https://github.com/dinhquan/iOSCleanArchitecture/blob/main/Images/HighLevel.png) 7 | 8 | The whole design architecture is separated into 4 rings: 9 | - **Entities**: Enterprise business rules 10 | - **UseCases**: Application business rules 11 | - **Data**: Network & Data persistent 12 | - **Application**: UI & Devices 13 | 14 | The most important rule is that the inner ring knows nothing about outer ring. Which means the variables, functions and classes (any entities) that exist in the outer layers can not be mentioned in the more inward levels. 15 | 16 | ## Detail overview 17 | 18 | ![alt text](https://github.com/dinhquan/iOSCleanArchitecture/blob/main/Images/DetailLevel.png) 19 | 20 | ### Domain 21 | **Entities** are implemented as Swift struct 22 | ```swift 23 | struct Article: Decodable { 24 | @Default.Empty var author: String 25 | @Default.Empty var title: String 26 | @Default.Empty var description: String 27 | @Default.Empty var url: String 28 | @Default.Empty var urlToImage: String 29 | @Default.Empty var publishedAt: String 30 | @Default.Empty var content: String 31 | } 32 | ``` 33 | 34 | **UseCases** are protocols 35 | ```swift 36 | protocol ArticleUseCase { 37 | func findArticlesByKeyword(_ keyword: String, pageSize: Int, page: Int) -> Single<[Article]> 38 | } 39 | 40 | ``` 41 | 42 | Domain layer doesn't depend on UIKit or any 3rd party framework. 43 | 44 | ### Data 45 | **Repositories** are concrete implementation of UseCases 46 | ```swift 47 | struct SearchArticleResult: Decodable { 48 | @Default.EmptyList var articles: [Article] 49 | @Default.Zero var totalResults: Int 50 | } 51 | 52 | struct ArticleRepository: ArticleUseCase { 53 | func findArticlesByKeyword(_ keyword: String, pageSize: Int, page: Int) -> Single<[Article]> { 54 | return ArticleService 55 | .searchArticlesByKeyword(q: keyword, pageSize: pageSize, page: page) 56 | .request(returnType: SearchArticleResult.self) 57 | .map { $0.articles } 58 | } 59 | } 60 | ``` 61 | 62 | ### Application 63 | Application is implemented with the MVVM pattern. The **ViewModel** performs pure transformation of a user Input to the Output 64 | ```swift 65 | protocol ViewModelProtocol { 66 | associatedtype Input 67 | associatedtype Output 68 | 69 | func transform(input: Input) -> Output 70 | } 71 | ``` 72 | ```swift 73 | struct ArticleListViewModel: ViewModelProtocol { 74 | struct Input { 75 | let search: Observable 76 | let loadMore: Observable 77 | } 78 | 79 | struct Output { 80 | let tableData: Driver<[SectionModel]> 81 | let fetching: Driver 82 | let error: Driver 83 | } 84 | 85 | @Injected var articleUseCase: ArticleUseCase 86 | 87 | func transform(input: Input) -> Output { 88 | ..... 89 | Observable.merge(search, loadMore) 90 | .flatMapLatest { keyword in 91 | return articleUseCase 92 | .findArticlesByKeyword(keyword, pageSize: pageSize, page: currentPage.value) 93 | .trackActivity(activityTracker) 94 | .trackError(errorTracker) 95 | .asDriver(onErrorJustReturn: []) 96 | } 97 | .subscribe(onNext: { articles in 98 | ..... 99 | } 100 | } 101 | ``` 102 | As you can see, `articleUseCase` is injected to ViewModel by `@Injected` annotation. Thanks to [Resolver](https://github.com/hmlongco/Resolver) library to make dependency injection easier. 103 | 104 | The **ViewModel** is injected to **ViewController** via **Navigator** 105 | ```swift 106 | struct ArticleNavigator { 107 | let navigationController: UINavigationController 108 | 109 | func showArticles() { 110 | let articleListViewController = Storyboard.load(.article, type: ArticleListViewController.self) 111 | articleListViewController.viewModel = ArticleListViewModel() 112 | articleListViewController.navigator = self 113 | navigationController.pushViewController(articleListViewController, animated: false) 114 | } 115 | ..... 116 | ``` 117 | ```swift 118 | final class ArticleListViewController: UIViewController { 119 | @IBOutlet weak var tableView: UITableView! 120 | @IBOutlet weak var searchBar: UISearchBar! 121 | 122 | private let bag = DisposeBag() 123 | 124 | var viewModel: ArticleListViewModel! 125 | var navigator: ArticleNavigator! 126 | ..... 127 | ``` 128 | 129 | ## Testing 130 | ### What to test 131 | In this architecture **ViewModels**, **UseCases** and **Entities** (if they contains business logic) can be tested. 132 | 133 | ### ViewModel tests 134 | To test the ViewModel you should have the RepositoryMock 135 | ```swift 136 | struct ArticleRepositoryMock: ArticleUseCase { 137 | func findArticlesByKeyword(_ keyword: String, pageSize: Int, page: Int) -> Single<[Article]> { 138 | return MockLoader 139 | .load(returnType: SearchArticleResult.self, file: "searchArticles.json") 140 | .map { $0.articles } 141 | } 142 | } 143 | ``` 144 | 145 | ```swift 146 | typealias ViewModel = ArticleListViewModel 147 | 148 | class ArticleListViewModelTests: XCTestCase { 149 | 150 | var testScheduler: TestScheduler! 151 | var viewModel: ViewModel! 152 | let bag = DisposeBag() 153 | 154 | override func setUpWithError() throws { 155 | testScheduler = TestScheduler(initialClock: 0) 156 | viewModel = ArticleListViewModel() 157 | viewModel.articleUseCase = ArticleRepositoryMock() 158 | } 159 | 160 | override func tearDownWithError() throws {} 161 | 162 | func test_searchWithKeyword() throws { 163 | // Given 164 | let search = testScheduler 165 | .createHotObservable([ 166 | .next(0, "Tesla") 167 | ]) 168 | .asObservable() 169 | let input = ViewModel.Input(search: search, loadMore: .never()) 170 | let output = viewModel.transform(input: input) 171 | 172 | // When 173 | testScheduler.start() 174 | let articles = try! output.tableData.articles.toBlocking().first()! 175 | 176 | // Then 177 | XCTAssertEqual(articles.count, 20) 178 | XCTAssertEqual(articles[1].author, "Mike Murphy") 179 | } 180 | 181 | func test_loadMore() throws { 182 | // Given 183 | let search = testScheduler 184 | .createHotObservable([ 185 | .next(0, "Tesla") 186 | ]) 187 | .asObservable() 188 | let loadMore = testScheduler 189 | .createHotObservable([ 190 | .next(2, ()) 191 | ]) 192 | .asObservable() 193 | let input = ViewModel.Input(search: search, loadMore: loadMore) 194 | let output = viewModel.transform(input: input) 195 | 196 | // When 197 | testScheduler.start() 198 | let articles = try! output.tableData.articles.toBlocking().first()! 199 | 200 | // Then 201 | XCTAssertEqual(articles.count, 40) 202 | } 203 | ``` 204 | 205 | ## Code generator 206 | The clean architecture, MVVM or VIPER will create a lot of files when you start a new module. So using a code generator is the smart way to save time. 207 | 208 | [codegen](https://github.com/dinhquan/codegen) is a great tool to do it. 209 | --------------------------------------------------------------------------------