diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..1bc7470 --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,78 @@ +# This is a basic workflow to help you get started with Actions + +name: CI Testing + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + + repository_dispatch: + types: [test] + pull_request: + branches: + - develop + - feature/* + - feature/*/* + - bugfix/* + - hotfix/* + - master + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + test: + name: Run Unit Test + runs-on: macOS-latest + strategy: + matrix: + devices: ["iPhone 8 (11.4)","iPhone SE (12.4)","iPhone X (13.6)","iPad Air (11.4)","iPad Air 2 (12.4)","iPad Pro (10.5-inch) (13.6)"] + steps: + - name: Checkout Branch + uses: actions/checkout@v1 + - name: Install Dependencies + run: | + bundle install --verbose + bash <(curl -s https://raw.githubusercontent.com/TitouanVanBelle/XCTestHTMLReport/master/install.sh) + bundle exec pod install + +# env: +# BUNDLE_GITHUB__COM: x-access-token:${{ secrets.GITHUB_PERSONAL_ACCESS_TOKEN }} + - name: Installing iOS Simulators + run: | + hostname + sudo mkdir -p /Library/Developer/CoreSimulator/Profiles/Runtimes + sudo ln -s /Applications/Xcode_10.3.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime /Library/Developer/CoreSimulator/Profiles/Runtimes/iOS\ 12.4.simruntime + sudo ln -s /Applications/Xcode_11.6.app//Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime /Library/Developer/CoreSimulator/Profiles/Runtimes/iOS\ 13.6.simruntime + bundle exec xcversion simulators --install="iOS 11.4 Simulator" --verbose + echo "Installed iOS Simulators" + - name: Creating iOS Simulators + run: | + xcrun simctl create "iPhone 8" com.apple.CoreSimulator.SimDeviceType.iPhone-8 com.apple.CoreSimulator.SimRuntime.iOS-11-4 + xcrun simctl create "iPhone SE" com.apple.CoreSimulator.SimDeviceType.iPhone-SE com.apple.CoreSimulator.SimRuntime.iOS-12-4 + xcrun simctl create "iPhone X" com.apple.CoreSimulator.SimDeviceType.iPhone-X "com.apple.CoreSimulator.SimRuntime.iOS-13-6" + xcrun simctl create "iPad Air" com.apple.CoreSimulator.SimDeviceType.iPad-Air "com.apple.CoreSimulator.SimRuntime.iOS-11-4" + xcrun simctl create "iPad Air 2" com.apple.CoreSimulator.SimDeviceType.iPad-Air-2 "com.apple.CoreSimulator.SimRuntime.iOS-12-4" + xcrun simctl create "iPad Pro (10.5-inch)" com.apple.CoreSimulator.SimDeviceType.iPad-Pro--10-5-inch- "com.apple.CoreSimulator.SimRuntime.iOS-13-6" + echo "Created iOS Simulators" + + - name: Build and test on Device + run: | + echo "Destination => ${destination}" + bundle exec fastlane run_ci_tests device:"${destination}" clean:true --verbose + env: + destination: ${{ matrix.devices }} + + - name: Archive Failed Tests artifacts + if: failure() + uses: actions/upload-artifact@v1 + with: + name: FailureDiff + path: fastlane/test_output + + - name: Archive Success Tests artifacts + if: success() + uses: actions/upload-artifact@v1 + with: + name: SuccessDiff + path: fastlane/test_output + + diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..01318f2 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,96 @@ +disabled_rules: + - line_length + - valid_docs + - type_body_length + - operator_whitespace + - statement_position + - trailing_whitespace + - nesting + - cyclomatic_complexity + - function_parameter_count + - vertical_parameter_alignment + - comma + - control_statement + +opt_in_rules: + # - function_body_length + - colon + - type_name + - closure_spacing + - redundant_nil_coalescing + # - attributes + - operator_usage_whitespace + - closure_end_indentation + - first_where + - object_literal + - number_separator + - fatal_error_message + # - missing_docs + +excluded: # paths to ignore during linting. Takes precedence over `included`. + - Carthage + - Pods + - build + +force_cast: warning +force_try: warning +force_unwrapping: error + +number_separator: + minimum_length: 5 + +function_body_length: + max_length: + warning: 75 + error : 150 + min_length: + warning: 0 + +variable_name: + max_length: + warning: 40 + error: 50 + min_length: + error: 3 + excluded: + - row + - key + - id + - url + - uri + - URI + - URL + - in + - to + - vc + - vw + - red + - rgb + - GlobalAPIKey + - up + - x + - y + +custom_rules: + empty_first_line: # from https://github.com/brandenr/swiftlintconfig + name: "Empty First Line" + regex: '(^[ a-zA-Z ]*(?:protocol|extension|class|struct) (?!(?:var|let))[ a-zA-Z:]*\{\n *\S+)' + message: "There should be an empty line after a declaration" + severity: warning + empty_line_after_guard: # from https://github.com/brandenr/swiftlintconfig + name: "Empty Line After Guard" + regex: '(^ *guard[ a-zA-Z0-9=?.\(\),>"; }; + 58F7ABB66E4031DDAE7CBDC7 /* Pods-DutchNewsUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DutchNewsUITests.debug.xcconfig"; path = "Target Support Files/Pods-DutchNewsUITests/Pods-DutchNewsUITests.debug.xcconfig"; sourceTree = ""; }; + 5BB041D0AA6B599D4F5E7D81 /* Pods-DutchNewsTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DutchNewsTests.debug.xcconfig"; path = "Target Support Files/Pods-DutchNewsTests/Pods-DutchNewsTests.debug.xcconfig"; sourceTree = ""; }; + 60D3AE4F11A8491BCCC459D7 /* Pods-DutchNews-DutchNewsUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DutchNews-DutchNewsUITests.release.xcconfig"; path = "Target Support Files/Pods-DutchNews-DutchNewsUITests/Pods-DutchNews-DutchNewsUITests.release.xcconfig"; sourceTree = ""; }; + 6D6B9A18971CCB93B1E1B562 /* Pods_DutchNews.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_DutchNews.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9F5E3588EBC310FDA84D4BC9 /* Pods-DutchNews.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DutchNews.release.xcconfig"; path = "Target Support Files/Pods-DutchNews/Pods-DutchNews.release.xcconfig"; sourceTree = ""; }; + C8E2BC6DABFC66D3F898B924 /* Pods-DutchNewsTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DutchNewsTests.release.xcconfig"; path = "Target Support Files/Pods-DutchNewsTests/Pods-DutchNewsTests.release.xcconfig"; sourceTree = ""; }; + CE0BB7F85E1175162F017DD0 /* Pods-DutchNews.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DutchNews.debug.xcconfig"; path = "Target Support Files/Pods-DutchNews/Pods-DutchNews.debug.xcconfig"; sourceTree = ""; }; + DD3A1F2B03FE42DB1EAECC2E /* Pods-DutchNewsUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-DutchNewsUITests.release.xcconfig"; path = "Target Support Files/Pods-DutchNewsUITests/Pods-DutchNewsUITests.release.xcconfig"; sourceTree = ""; }; + E4DDFCE580208FED7E50E9D2 /* Pods_DutchNewsTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_DutchNewsTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F8154D4D2517D28700BFB42C /* HeadlinesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinesViewController.swift; sourceTree = ""; }; + F8154D512517EBC700BFB42C /* HeadlinesViewController+MagazineLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HeadlinesViewController+MagazineLayout.swift"; sourceTree = ""; }; + F8154D542517EC0D00BFB42C /* HeadlineLayoutConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlineLayoutConfiguration.swift; sourceTree = ""; }; + F8154D562517F03600BFB42C /* ArticleHeadlineLayoutConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleHeadlineLayoutConfiguration.swift; sourceTree = ""; }; + F8154D5F2518011500BFB42C /* MainArticleCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainArticleCollectionViewCell.swift; sourceTree = ""; }; + F8154D602518011500BFB42C /* MainArticleCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainArticleCollectionViewCell.xib; sourceTree = ""; }; + F8154D632518012F00BFB42C /* ArticleRowCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleRowCollectionViewCell.swift; sourceTree = ""; }; + F8154D642518012F00BFB42C /* ArticleRowCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ArticleRowCollectionViewCell.xib; sourceTree = ""; }; + F8154D672518016800BFB42C /* HalfWidthArticleCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HalfWidthArticleCollectionViewCell.swift; sourceTree = ""; }; + F8154D682518016800BFB42C /* HalfWidthArticleCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HalfWidthArticleCollectionViewCell.xib; sourceTree = ""; }; + F8154D6B25180A9000BFB42C /* HeadlineBaseCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlineBaseCollectionViewCell.swift; sourceTree = ""; }; + F8154D6E25180B0200BFB42C /* UIView+Nib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Nib.swift"; sourceTree = ""; }; + F8154D7025180C3900BFB42C /* ArticleWebContainerCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleWebContainerCollectionViewCell.swift; sourceTree = ""; }; + F8154D7225180F0E00BFB42C /* ArticleWebContainerCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ArticleWebContainerCollectionViewCell.xib; sourceTree = ""; }; + F8154D742518156000BFB42C /* HeadlinesViewController+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HeadlinesViewController+DataSource.swift"; sourceTree = ""; }; + F8154D782518207B00BFB42C /* String+HTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+HTML.swift"; sourceTree = ""; }; + F815729C251D3077009DBFD7 /* ArticleDetailsPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleDetailsPageViewModel.swift; sourceTree = ""; }; + F815729F251D3254009DBFD7 /* ArticlesPageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticlesPageViewModel.swift; sourceTree = ""; }; + F81572A1251D37F7009DBFD7 /* ArticleDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleDetailViewModel.swift; sourceTree = ""; }; + F81572A3251D3B39009DBFD7 /* ArticlesPageUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticlesPageUseCase.swift; sourceTree = ""; }; + F81572BC251D4839009DBFD7 /* Screen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = ""; }; + F81572BE251D487E009DBFD7 /* ScreenEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenEnum.swift; sourceTree = ""; }; + F81572C2251D4AB2009DBFD7 /* ViewControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerFactory.swift; sourceTree = ""; }; + F81572C4251D4BEB009DBFD7 /* UIStoryboard+Additional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIStoryboard+Additional.swift"; sourceTree = ""; }; + F82C8EFC2516304D002B27B3 /* APIClientServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientServiceTests.swift; sourceTree = ""; }; + F82C8EFE25163073002B27B3 /* NetworkMocking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMocking.swift; sourceTree = ""; }; + F82C8F0025163898002B27B3 /* NetworkMockingDataFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMockingDataFactory.swift; sourceTree = ""; }; + F841DD0925195415006E7E90 /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; + F841DD0B25195429006E7E90 /* LinearGradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinearGradientView.swift; sourceTree = ""; }; + F8505758251E066F00257884 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + F8505759251E066F00257884 /* nl-NL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "nl-NL"; path = "nl-NL.lproj/Localizable.strings"; sourceTree = ""; }; + F858997A25176DC800A6BA2A /* Article.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Article.swift; sourceTree = ""; }; + F858997C25176E8F00A6BA2A /* ArticleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleSource.swift; sourceTree = ""; }; + F858997F251772CC00A6BA2A /* ModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTests.swift; sourceTree = ""; }; + F85899812517738C00A6BA2A /* ModelsDataFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelsDataFactory.swift; sourceTree = ""; }; + F858998325177D6200A6BA2A /* Articles.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = Articles.json; sourceTree = ""; }; + F8589985251784B200A6BA2A /* ArticleRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleRepository.swift; sourceTree = ""; }; + F85899872517894B00A6BA2A /* HeadlinesArticleRemoteRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinesArticleRemoteRepository.swift; sourceTree = ""; }; + F858998C2517909A00A6BA2A /* MockArticleValidResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockArticleValidResponse.swift; sourceTree = ""; }; + F858998F2517910700A6BA2A /* HeadlinesArticleRemoteRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinesArticleRemoteRepositoryTests.swift; sourceTree = ""; }; + F85E318B251B8462002753AC /* ArticlesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticlesPageViewController.swift; sourceTree = ""; }; + F85E318D251B8484002753AC /* ArticleDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleDetailViewController.swift; sourceTree = ""; }; + F85E3190251B85E4002753AC /* ArticleDetailHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleDetailHeaderView.swift; sourceTree = ""; }; + F85E3194251B8665002753AC /* ArticleDetailHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ArticleDetailHeaderView.xib; sourceTree = ""; }; + F85E3197251B8D60002753AC /* Rx+WebKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Rx+WebKit.swift"; sourceTree = ""; }; + F85E3198251B8D60002753AC /* RxWKUIDelegateProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RxWKUIDelegateProxy.swift; sourceTree = ""; }; + F85E3199251B8D60002753AC /* WKNavigationDelegateEvents+Rx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WKNavigationDelegateEvents+Rx.swift"; sourceTree = ""; }; + F85E319A251B8D61002753AC /* RxWKUIDelegateEvents+Rx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "RxWKUIDelegateEvents+Rx.swift"; sourceTree = ""; }; + F85E319B251B8D61002753AC /* RxWKUserContentController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RxWKUserContentController.swift; sourceTree = ""; }; + F85E319C251B8D61002753AC /* RxWKNavigationDelegateProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RxWKNavigationDelegateProxy.swift; sourceTree = ""; }; + F865F74B251686D2001FD067 /* Authenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Authenticator.swift; sourceTree = ""; }; + F865F74D251687E4001FD067 /* APIAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIAuthenticatorTests.swift; sourceTree = ""; }; + F865F74F25168F05001FD067 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = ""; }; + F865F75F2516A643001FD067 /* APIServerResponseStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServerResponseStatus.swift; sourceTree = ""; }; + F865F7612516A6C1001FD067 /* APIServerResponseError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServerResponseError.swift; sourceTree = ""; }; + F865F7632516A9D5001FD067 /* APIServerResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServerResponse.swift; sourceTree = ""; }; + F865F7652516AB66001FD067 /* APIServerResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIServerResponseTests.swift; sourceTree = ""; }; + F865F76A2516C08C001FD067 /* NetworkValidResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkValidResponse.swift; sourceTree = ""; }; + F865F76C2516C826001FD067 /* DefaultAPIValidResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAPIValidResponse.swift; sourceTree = ""; }; + F884C0FC251D2CAE0078E88B /* ArticlesUseCases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticlesUseCases.swift; sourceTree = ""; }; + F8866A3E251EABE8008AF310 /* HeadlineSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlineSearchViewController.swift; sourceTree = ""; }; + F8866A40251EC3BC008AF310 /* HeadlineSearchTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlineSearchTableViewCell.swift; sourceTree = ""; }; + F8866A41251EC3BC008AF310 /* HeadlineSearchTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HeadlineSearchTableViewCell.xib; sourceTree = ""; }; + F88800682517A423008DCC54 /* RepositoryDependenciesFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDependenciesFactory.swift; sourceTree = ""; }; + F888006A2517A8EE008DCC54 /* HeadlineSuccessResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = HeadlineSuccessResponse.json; sourceTree = ""; }; + F888006C2517AA1F008DCC54 /* HeadlineFailureResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = HeadlineFailureResponse.json; sourceTree = ""; }; + F897BD3B251D5C7E003822EA /* ViewModelViewControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelViewControllerFactory.swift; sourceTree = ""; }; + F897BD3D251D740C003822EA /* RxHeadlinesDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RxHeadlinesDataSource.swift; sourceTree = ""; }; F89B021A250D446000B41293 /* DutchNews.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DutchNews.app; sourceTree = BUILT_PRODUCTS_DIR; }; F89B021D250D446000B41293 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; F89B021F250D446200B41293 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -40,9 +217,42 @@ F89B0229250D446200B41293 /* DutchNewsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DutchNewsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F89B022D250D446200B41293 /* DutchNewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DutchNewsTests.swift; sourceTree = ""; }; F89B022F250D446200B41293 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - F89B0234250D446200B41293 /* DutchNewsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DutchNewsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - F89B0238250D446200B41293 /* DutchNewsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DutchNewsUITests.swift; sourceTree = ""; }; - F89B023A250D446200B41293 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F8C8345B251C04010051A0FD /* CodableDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodableDataManager.swift; sourceTree = ""; }; + F8C8345E251C04010051A0FD /* Storable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storable.swift; sourceTree = ""; }; + F8C8345F251C04010051A0FD /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; + F8C83465251C1F480051A0FD /* CodableDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableDataManagerTests.swift; sourceTree = ""; }; + F8C83467251C22C80051A0FD /* DispatchQueue+Additonals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Additonals.swift"; sourceTree = ""; }; + F8D58B82251CFC1E00B426AC /* HeadlinesArticleLocalRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinesArticleLocalRepository.swift; sourceTree = ""; }; + F8D58B84251D04F900B426AC /* HeadlinesArticleLocalRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinesArticleLocalRepositoryTests.swift; sourceTree = ""; }; + F8DE79E22515904400A6C2D5 /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; + F8DE79E4251594D700A6C2D5 /* APIClientService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClientService.swift; sourceTree = ""; }; + F8E5C0E8251881C80083D2B1 /* AlertableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertableView.swift; sourceTree = ""; }; + F8E5C0EA251882560083D2B1 /* UIViewController+AlertableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AlertableView.swift"; sourceTree = ""; }; + F8E5C0EC2518833B0083D2B1 /* ArticlesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticlesViewModel.swift; sourceTree = ""; }; + F8E5C0EE2518848D0083D2B1 /* HeadlinesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinesViewModel.swift; sourceTree = ""; }; + F8E5C0F0251884A20083D2B1 /* HeadlineSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlineSearchViewModel.swift; sourceTree = ""; }; + F8E5C0F22518D8AC0083D2B1 /* ArticleViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleViewModel.swift; sourceTree = ""; }; + F8E5C0F42518DD3E0083D2B1 /* ViewModelState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelState.swift; sourceTree = ""; }; + F8E5C0F72518E7770083D2B1 /* HeadlinesUseCases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinesUseCases.swift; sourceTree = ""; }; + F8E5C0F92518E8100083D2B1 /* HeadlinesFetchingUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinesFetchingUseCase.swift; sourceTree = ""; }; + F8E5C0FB2518E9150083D2B1 /* HeadlinesSearchingUseCases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlinesSearchingUseCases.swift; sourceTree = ""; }; + F8E5C0FD25190CDD0083D2B1 /* HeadlineCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlineCellViewModel.swift; sourceTree = ""; }; + F8E5C11825191D5D0083D2B1 /* Date+Convertor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+Convertor.swift"; sourceTree = ""; }; + F8E5C11925191D5D0083D2B1 /* Collection+Additionals.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Collection+Additionals.swift"; sourceTree = ""; }; + F8E5C11A25191D5D0083D2B1 /* Date+TimeAgo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = ""; }; + F8E5C11B25191D5D0083D2B1 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; + F8E5C11C25191D5D0083D2B1 /* String+EmptyChecking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+EmptyChecking.swift"; sourceTree = ""; }; + F8E5C11D25191D5D0083D2B1 /* URL+ApplicationPath.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+ApplicationPath.swift"; sourceTree = ""; }; + F8E5C12425191DA50083D2B1 /* UIImage+Additionals.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Additionals.swift"; sourceTree = ""; }; + F8E5C12525191DA50083D2B1 /* UINavigationBar+Additionals.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Additionals.swift"; sourceTree = ""; }; + F8E5C12625191DA50083D2B1 /* UILabel+Localization.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UILabel+Localization.swift"; sourceTree = ""; }; + F8E5C12725191DA50083D2B1 /* UIViewController+StoryboardName.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewController+StoryboardName.swift"; sourceTree = ""; }; + F8E5C12825191DA60083D2B1 /* UIImageView+SDWebImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImageView+SDWebImage.swift"; sourceTree = ""; }; + F8E5C12925191DA60083D2B1 /* UIColor+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Extension.swift"; sourceTree = ""; }; + F8E5C1302519250E0083D2B1 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + F8E5C13225193A290083D2B1 /* String+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Localization.swift"; sourceTree = ""; }; + F8E5C134251943CF0083D2B1 /* AppDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDIContainer.swift; sourceTree = ""; }; + F8F14C73250D719800C24FF5 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -50,6 +260,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 21E07E636FF6F2984928E95F /* Pods_DutchNews.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -57,26 +268,232 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - F89B0231250D446200B41293 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( + D9C19360BE6ACB6A2FE7DA86 /* Pods_DutchNewsTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 4B3F257B8F205BA3F43A73E1 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 6D6B9A18971CCB93B1E1B562 /* Pods_DutchNews.framework */, + E4DDFCE580208FED7E50E9D2 /* Pods_DutchNewsTests.framework */, + 06FCAD588464F494AF84398E /* Pods_DutchNewsUITests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + F8154D532517EBE300BFB42C /* HeadlineLayoutConfiguration */ = { + isa = PBXGroup; + children = ( + F8154D542517EC0D00BFB42C /* HeadlineLayoutConfiguration.swift */, + F8154D562517F03600BFB42C /* ArticleHeadlineLayoutConfiguration.swift */, + ); + path = HeadlineLayoutConfiguration; + sourceTree = ""; + }; + F8154D5A251800E400BFB42C /* Cells */ = { + isa = PBXGroup; + children = ( + F8154D6B25180A9000BFB42C /* HeadlineBaseCollectionViewCell.swift */, + F8154D5F2518011500BFB42C /* MainArticleCollectionViewCell.swift */, + F8154D602518011500BFB42C /* MainArticleCollectionViewCell.xib */, + F8154D632518012F00BFB42C /* ArticleRowCollectionViewCell.swift */, + F8154D642518012F00BFB42C /* ArticleRowCollectionViewCell.xib */, + F8154D672518016800BFB42C /* HalfWidthArticleCollectionViewCell.swift */, + F8154D682518016800BFB42C /* HalfWidthArticleCollectionViewCell.xib */, + F8154D7025180C3900BFB42C /* ArticleWebContainerCollectionViewCell.swift */, + F8154D7225180F0E00BFB42C /* ArticleWebContainerCollectionViewCell.xib */, + F8866A40251EC3BC008AF310 /* HeadlineSearchTableViewCell.swift */, + F8866A41251EC3BC008AF310 /* HeadlineSearchTableViewCell.xib */, + ); + path = Cells; + sourceTree = ""; + }; + F8154D6D25180AF100BFB42C /* UI */ = { + isa = PBXGroup; + children = ( + F8E5C12925191DA60083D2B1 /* UIColor+Extension.swift */, + F8E5C12425191DA50083D2B1 /* UIImage+Additionals.swift */, + F8E5C12825191DA60083D2B1 /* UIImageView+SDWebImage.swift */, + F8E5C12625191DA50083D2B1 /* UILabel+Localization.swift */, + F8E5C12525191DA50083D2B1 /* UINavigationBar+Additionals.swift */, + F8E5C12725191DA50083D2B1 /* UIViewController+StoryboardName.swift */, + F8154D6E25180B0200BFB42C /* UIView+Nib.swift */, + F8E5C0EA251882560083D2B1 /* UIViewController+AlertableView.swift */, + F81572C4251D4BEB009DBFD7 /* UIStoryboard+Additional.swift */, + ); + path = UI; + sourceTree = ""; + }; + F815729E251D323D009DBFD7 /* Abstract */ = { + isa = PBXGroup; + children = ( + F8E5C0F22518D8AC0083D2B1 /* ArticleViewModel.swift */, + F8E5C0EC2518833B0083D2B1 /* ArticlesViewModel.swift */, + F815729F251D3254009DBFD7 /* ArticlesPageViewModel.swift */, + ); + path = Abstract; + sourceTree = ""; + }; + F81572BB251D4819009DBFD7 /* ViewControllerFactory */ = { + isa = PBXGroup; + children = ( + F81572BC251D4839009DBFD7 /* Screen.swift */, + F81572BE251D487E009DBFD7 /* ScreenEnum.swift */, + F81572C2251D4AB2009DBFD7 /* ViewControllerFactory.swift */, + F897BD3B251D5C7E003822EA /* ViewModelViewControllerFactory.swift */, + ); + path = ViewControllerFactory; + sourceTree = ""; + }; + F82C8EFB2516051D002B27B3 /* NetworkTests */ = { + isa = PBXGroup; + children = ( + F82C8F0225163931002B27B3 /* Helper */, + F82C8EFC2516304D002B27B3 /* APIClientServiceTests.swift */, + F865F74D251687E4001FD067 /* APIAuthenticatorTests.swift */, + F865F7652516AB66001FD067 /* APIServerResponseTests.swift */, + ); + path = NetworkTests; + sourceTree = ""; + }; + F82C8F0225163931002B27B3 /* Helper */ = { + isa = PBXGroup; + children = ( + F82C8EFE25163073002B27B3 /* NetworkMocking.swift */, + F82C8F0025163898002B27B3 /* NetworkMockingDataFactory.swift */, + ); + path = Helper; + sourceTree = ""; + }; + F841DD08251953F9006E7E90 /* GradientView */ = { + isa = PBXGroup; + children = ( + F841DD0925195415006E7E90 /* GradientView.swift */, + F841DD0B25195429006E7E90 /* LinearGradientView.swift */, + ); + path = GradientView; + sourceTree = ""; + }; + F8505756251E066F00257884 /* Localization */ = { + isa = PBXGroup; + children = ( + F8505757251E066F00257884 /* Localizable.strings */, + ); + path = Localization; + sourceTree = ""; + }; + F858997925176D7700A6BA2A /* Models */ = { + isa = PBXGroup; + children = ( + F858997A25176DC800A6BA2A /* Article.swift */, + F858997C25176E8F00A6BA2A /* ArticleSource.swift */, + ); + path = Models; + sourceTree = ""; + }; + F858997E251772AF00A6BA2A /* ModelsTests */ = { + isa = PBXGroup; + children = ( + F858997F251772CC00A6BA2A /* ModelTests.swift */, + F85899812517738C00A6BA2A /* ModelsDataFactory.swift */, + F858998325177D6200A6BA2A /* Articles.json */, + ); + path = ModelsTests; + sourceTree = ""; + }; + F858998B2517906B00A6BA2A /* Repositories */ = { + isa = PBXGroup; + children = ( + F858998C2517909A00A6BA2A /* MockArticleValidResponse.swift */, + F88800682517A423008DCC54 /* RepositoryDependenciesFactory.swift */, + F858998E251790DF00A6BA2A /* HeadLines */, + ); + path = Repositories; + sourceTree = ""; + }; + F858998E251790DF00A6BA2A /* HeadLines */ = { + isa = PBXGroup; + children = ( + F888006A2517A8EE008DCC54 /* HeadlineSuccessResponse.json */, + F888006C2517AA1F008DCC54 /* HeadlineFailureResponse.json */, + F858998F2517910700A6BA2A /* HeadlinesArticleRemoteRepositoryTests.swift */, + F8D58B84251D04F900B426AC /* HeadlinesArticleLocalRepositoryTests.swift */, + ); + path = HeadLines; + sourceTree = ""; + }; + F85E318F251B85B0002753AC /* Headers */ = { + isa = PBXGroup; + children = ( + F85E3190251B85E4002753AC /* ArticleDetailHeaderView.swift */, + F85E3194251B8665002753AC /* ArticleDetailHeaderView.xib */, + ); + path = Headers; + sourceTree = ""; + }; + F85E3196251B8D56002753AC /* WebKit */ = { + isa = PBXGroup; + children = ( + F85E3197251B8D60002753AC /* Rx+WebKit.swift */, + F85E319C251B8D61002753AC /* RxWKNavigationDelegateProxy.swift */, + F85E319A251B8D61002753AC /* RxWKUIDelegateEvents+Rx.swift */, + F85E3198251B8D60002753AC /* RxWKUIDelegateProxy.swift */, + F85E319B251B8D61002753AC /* RxWKUserContentController.swift */, + F85E3199251B8D60002753AC /* WKNavigationDelegateEvents+Rx.swift */, + ); + path = WebKit; + sourceTree = ""; + }; + F865F74A251686C1001FD067 /* Authenticator */ = { + isa = PBXGroup; + children = ( + F865F74B251686D2001FD067 /* Authenticator.swift */, + ); + path = Authenticator; + sourceTree = ""; + }; + F865F7512516998A001FD067 /* Repositories */ = { + isa = PBXGroup; + children = ( + F8589985251784B200A6BA2A /* ArticleRepository.swift */, + F85899872517894B00A6BA2A /* HeadlinesArticleRemoteRepository.swift */, + F8D58B82251CFC1E00B426AC /* HeadlinesArticleLocalRepository.swift */, + ); + path = Repositories; + sourceTree = ""; + }; + F865F75C2516A4E6001FD067 /* Response */ = { + isa = PBXGroup; + children = ( + F865F75F2516A643001FD067 /* APIServerResponseStatus.swift */, + F865F7612516A6C1001FD067 /* APIServerResponseError.swift */, + F865F7632516A9D5001FD067 /* APIServerResponse.swift */, + F865F76C2516C826001FD067 /* DefaultAPIValidResponse.swift */, + ); + path = Response; + sourceTree = ""; + }; + F884C0FA251D1FCC0078E88B /* Abstract */ = { + isa = PBXGroup; + children = ( + F8E5C0F72518E7770083D2B1 /* HeadlinesUseCases.swift */, + F884C0FC251D2CAE0078E88B /* ArticlesUseCases.swift */, + ); + path = Abstract; + sourceTree = ""; + }; F89B0211250D446000B41293 = { isa = PBXGroup; children = ( F89B021C250D446000B41293 /* DutchNews */, F89B022C250D446200B41293 /* DutchNewsTests */, - F89B0237250D446200B41293 /* DutchNewsUITests */, F89B021B250D446000B41293 /* Products */, + FA6C9BAEFCB0D31A29283E4B /* Pods */, + 4B3F257B8F205BA3F43A73E1 /* Frameworks */, ); sourceTree = ""; }; @@ -85,7 +502,6 @@ children = ( F89B021A250D446000B41293 /* DutchNews.app */, F89B0229250D446200B41293 /* DutchNewsTests.xctest */, - F89B0234250D446200B41293 /* DutchNewsUITests.xctest */, ); name = Products; sourceTree = ""; @@ -93,10 +509,12 @@ F89B021C250D446000B41293 /* DutchNews */ = { isa = PBXGroup; children = ( + F8F14C68250D70AA00C24FF5 /* Classes */, + F8F14C70250D712400C24FF5 /* Resources */, F89B021D250D446000B41293 /* AppDelegate.swift */, - F89B021F250D446200B41293 /* Assets.xcassets */, - F89B0221250D446200B41293 /* LaunchScreen.storyboard */, F89B0224250D446200B41293 /* Info.plist */, + F865F74F25168F05001FD067 /* AppConfig.swift */, + F8E5C134251943CF0083D2B1 /* AppDIContainer.swift */, ); path = DutchNews; sourceTree = ""; @@ -104,19 +522,205 @@ F89B022C250D446200B41293 /* DutchNewsTests */ = { isa = PBXGroup; children = ( + F8C83464251C1F0B0051A0FD /* Presistence */, + F858998B2517906B00A6BA2A /* Repositories */, + F858997E251772AF00A6BA2A /* ModelsTests */, + F82C8EFB2516051D002B27B3 /* NetworkTests */, F89B022D250D446200B41293 /* DutchNewsTests.swift */, F89B022F250D446200B41293 /* Info.plist */, ); path = DutchNewsTests; sourceTree = ""; }; - F89B0237250D446200B41293 /* DutchNewsUITests */ = { + F8C83457251C018F0051A0FD /* Persistence */ = { + isa = PBXGroup; + children = ( + F8C8345D251C04010051A0FD /* Abstracts */, + F8C8345A251C04010051A0FD /* Database */, + ); + path = Persistence; + sourceTree = ""; + }; + F8C8345A251C04010051A0FD /* Database */ = { + isa = PBXGroup; + children = ( + F8C8345B251C04010051A0FD /* CodableDataManager.swift */, + ); + path = Database; + sourceTree = ""; + }; + F8C8345D251C04010051A0FD /* Abstracts */ = { + isa = PBXGroup; + children = ( + F8C8345E251C04010051A0FD /* Storable.swift */, + F8C8345F251C04010051A0FD /* Storage.swift */, + ); + path = Abstracts; + sourceTree = ""; + }; + F8C83464251C1F0B0051A0FD /* Presistence */ = { + isa = PBXGroup; + children = ( + F8C83465251C1F480051A0FD /* CodableDataManagerTests.swift */, + ); + path = Presistence; + sourceTree = ""; + }; + F8E5C0E7251881400083D2B1 /* AlertView */ = { + isa = PBXGroup; + children = ( + F8E5C0E8251881C80083D2B1 /* AlertableView.swift */, + ); + path = AlertView; + sourceTree = ""; + }; + F8E5C0F62518E7300083D2B1 /* Domains */ = { + isa = PBXGroup; + children = ( + F884C0FA251D1FCC0078E88B /* Abstract */, + F8E5C0F92518E8100083D2B1 /* HeadlinesFetchingUseCase.swift */, + F8E5C0FB2518E9150083D2B1 /* HeadlinesSearchingUseCases.swift */, + F81572A3251D3B39009DBFD7 /* ArticlesPageUseCase.swift */, + ); + path = Domains; + sourceTree = ""; + }; + F8F14C68250D70AA00C24FF5 /* Classes */ = { + isa = PBXGroup; + children = ( + F8F14C6C250D70CF00C24FF5 /* ViewControllers */, + F8F14C6B250D70C700C24FF5 /* ViewModels */, + F8F14C6A250D70BD00C24FF5 /* Views */, + F858997925176D7700A6BA2A /* Models */, + F8E5C0F62518E7300083D2B1 /* Domains */, + F8F14C69250D70B400C24FF5 /* Data Layers */, + F8F14C6F250D710100C24FF5 /* Extensions */, + F8F14C6D250D70DC00C24FF5 /* Utilites */, + ); + path = Classes; + sourceTree = ""; + }; + F8F14C69250D70B400C24FF5 /* Data Layers */ = { + isa = PBXGroup; + children = ( + F865F7512516998A001FD067 /* Repositories */, + F865F74A251686C1001FD067 /* Authenticator */, + F8F14C6E250D70F900C24FF5 /* Networking */, + F8C83457251C018F0051A0FD /* Persistence */, + ); + path = "Data Layers"; + sourceTree = ""; + }; + F8F14C6A250D70BD00C24FF5 /* Views */ = { isa = PBXGroup; children = ( - F89B0238250D446200B41293 /* DutchNewsUITests.swift */, - F89B023A250D446200B41293 /* Info.plist */, + F85E318F251B85B0002753AC /* Headers */, + F841DD08251953F9006E7E90 /* GradientView */, + F8E5C0E7251881400083D2B1 /* AlertView */, + F8154D5A251800E400BFB42C /* Cells */, ); - path = DutchNewsUITests; + path = Views; + sourceTree = ""; + }; + F8F14C6B250D70C700C24FF5 /* ViewModels */ = { + isa = PBXGroup; + children = ( + F815729E251D323D009DBFD7 /* Abstract */, + F8E5C0F42518DD3E0083D2B1 /* ViewModelState.swift */, + F8E5C0EE2518848D0083D2B1 /* HeadlinesViewModel.swift */, + F8E5C0F0251884A20083D2B1 /* HeadlineSearchViewModel.swift */, + F8E5C0FD25190CDD0083D2B1 /* HeadlineCellViewModel.swift */, + F815729C251D3077009DBFD7 /* ArticleDetailsPageViewModel.swift */, + F81572A1251D37F7009DBFD7 /* ArticleDetailViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + F8F14C6C250D70CF00C24FF5 /* ViewControllers */ = { + isa = PBXGroup; + children = ( + F8154D4D2517D28700BFB42C /* HeadlinesViewController.swift */, + F8154D742518156000BFB42C /* HeadlinesViewController+DataSource.swift */, + F8154D512517EBC700BFB42C /* HeadlinesViewController+MagazineLayout.swift */, + F8866A3E251EABE8008AF310 /* HeadlineSearchViewController.swift */, + F85E318B251B8462002753AC /* ArticlesPageViewController.swift */, + F85E318D251B8484002753AC /* ArticleDetailViewController.swift */, + ); + path = ViewControllers; + sourceTree = ""; + }; + F8F14C6D250D70DC00C24FF5 /* Utilites */ = { + isa = PBXGroup; + children = ( + F81572BB251D4819009DBFD7 /* ViewControllerFactory */, + F8154D532517EBE300BFB42C /* HeadlineLayoutConfiguration */, + F8E5C1302519250E0083D2B1 /* Logger.swift */, + F897BD3D251D740C003822EA /* RxHeadlinesDataSource.swift */, + ); + path = Utilites; + sourceTree = ""; + }; + F8F14C6E250D70F900C24FF5 /* Networking */ = { + isa = PBXGroup; + children = ( + F865F75C2516A4E6001FD067 /* Response */, + F865F76A2516C08C001FD067 /* NetworkValidResponse.swift */, + F8DE79E22515904400A6C2D5 /* NetworkService.swift */, + F8DE79E4251594D700A6C2D5 /* APIClientService.swift */, + ); + path = Networking; + sourceTree = ""; + }; + F8F14C6F250D710100C24FF5 /* Extensions */ = { + isa = PBXGroup; + children = ( + F85E3196251B8D56002753AC /* WebKit */, + F8154D6D25180AF100BFB42C /* UI */, + F8E5C11B25191D5D0083D2B1 /* Bundle+Extensions.swift */, + F8E5C11925191D5D0083D2B1 /* Collection+Additionals.swift */, + F8E5C11825191D5D0083D2B1 /* Date+Convertor.swift */, + F8E5C11A25191D5D0083D2B1 /* Date+TimeAgo.swift */, + F8E5C11C25191D5D0083D2B1 /* String+EmptyChecking.swift */, + F8E5C11D25191D5D0083D2B1 /* URL+ApplicationPath.swift */, + F8154D782518207B00BFB42C /* String+HTML.swift */, + F8E5C13225193A290083D2B1 /* String+Localization.swift */, + F8C83467251C22C80051A0FD /* DispatchQueue+Additonals.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + F8F14C70250D712400C24FF5 /* Resources */ = { + isa = PBXGroup; + children = ( + F8505756251E066F00257884 /* Localization */, + F8F14C71250D716A00C24FF5 /* Storyboards */, + F89B021F250D446200B41293 /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + F8F14C71250D716A00C24FF5 /* Storyboards */ = { + isa = PBXGroup; + children = ( + F89B0221250D446200B41293 /* LaunchScreen.storyboard */, + F8F14C72250D719800C24FF5 /* Main.storyboard */, + ); + path = Storyboards; + sourceTree = ""; + }; + FA6C9BAEFCB0D31A29283E4B /* Pods */ = { + isa = PBXGroup; + children = ( + CE0BB7F85E1175162F017DD0 /* Pods-DutchNews.debug.xcconfig */, + 9F5E3588EBC310FDA84D4BC9 /* Pods-DutchNews.release.xcconfig */, + 4F28822A80945682344AAC12 /* Pods-DutchNews-DutchNewsUITests.debug.xcconfig */, + 60D3AE4F11A8491BCCC459D7 /* Pods-DutchNews-DutchNewsUITests.release.xcconfig */, + 5BB041D0AA6B599D4F5E7D81 /* Pods-DutchNewsTests.debug.xcconfig */, + C8E2BC6DABFC66D3F898B924 /* Pods-DutchNewsTests.release.xcconfig */, + 58F7ABB66E4031DDAE7CBDC7 /* Pods-DutchNewsUITests.debug.xcconfig */, + DD3A1F2B03FE42DB1EAECC2E /* Pods-DutchNewsUITests.release.xcconfig */, + ); + path = Pods; sourceTree = ""; }; /* End PBXGroup section */ @@ -126,9 +730,12 @@ isa = PBXNativeTarget; buildConfigurationList = F89B023D250D446200B41293 /* Build configuration list for PBXNativeTarget "DutchNews" */; buildPhases = ( + ED77C5D597B0DE2AE4BC3C93 /* [CP] Check Pods Manifest.lock */, + F8E5C105251915CE0083D2B1 /* Swift Lint Run Script */, F89B0216250D446000B41293 /* Sources */, F89B0217250D446000B41293 /* Frameworks */, F89B0218250D446000B41293 /* Resources */, + 2A72020948C2D75438B5DA75 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -143,9 +750,11 @@ isa = PBXNativeTarget; buildConfigurationList = F89B0240250D446200B41293 /* Build configuration list for PBXNativeTarget "DutchNewsTests" */; buildPhases = ( + EF9A2F60853CCD39B6A80215 /* [CP] Check Pods Manifest.lock */, F89B0225250D446200B41293 /* Sources */, F89B0226250D446200B41293 /* Frameworks */, F89B0227250D446200B41293 /* Resources */, + B48216F6A905881BBE665BFD /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -157,24 +766,6 @@ productReference = F89B0229250D446200B41293 /* DutchNewsTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - F89B0233250D446200B41293 /* DutchNewsUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = F89B0243250D446200B41293 /* Build configuration list for PBXNativeTarget "DutchNewsUITests" */; - buildPhases = ( - F89B0230250D446200B41293 /* Sources */, - F89B0231250D446200B41293 /* Frameworks */, - F89B0232250D446200B41293 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - F89B0236250D446200B41293 /* PBXTargetDependency */, - ); - name = DutchNewsUITests; - productName = DutchNewsUITests; - productReference = F89B0234250D446200B41293 /* DutchNewsUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -187,13 +778,11 @@ TargetAttributes = { F89B0219250D446000B41293 = { CreatedOnToolsVersion = 11.2.1; + ProvisioningStyle = Automatic; }; F89B0228250D446200B41293 = { CreatedOnToolsVersion = 11.2.1; - TestTargetID = F89B0219250D446000B41293; - }; - F89B0233250D446200B41293 = { - CreatedOnToolsVersion = 11.2.1; + ProvisioningStyle = Automatic; TestTargetID = F89B0219250D446000B41293; }; }; @@ -205,6 +794,7 @@ knownRegions = ( en, Base, + "nl-NL", ); mainGroup = F89B0211250D446000B41293; productRefGroup = F89B021B250D446000B41293 /* Products */; @@ -213,7 +803,6 @@ targets = ( F89B0219250D446000B41293 /* DutchNews */, F89B0228250D446200B41293 /* DutchNewsTests */, - F89B0233250D446200B41293 /* DutchNewsUITests */, ); }; /* End PBXProject section */ @@ -223,7 +812,16 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + F8154D7625181B1C00BFB42C /* ArticleWebContainerCollectionViewCell.xib in Resources */, + F8F14C74250D719800C24FF5 /* Main.storyboard in Resources */, + F8154D6A2518016800BFB42C /* HalfWidthArticleCollectionViewCell.xib in Resources */, + F85E3195251B8665002753AC /* ArticleDetailHeaderView.xib in Resources */, F89B0220250D446200B41293 /* Assets.xcassets in Resources */, + F850575A251E066F00257884 /* Localizable.strings in Resources */, + F8866A43251EC3BC008AF310 /* HeadlineSearchTableViewCell.xib in Resources */, + F8154D622518011500BFB42C /* MainArticleCollectionViewCell.xib in Resources */, + F8154D662518012F00BFB42C /* ArticleRowCollectionViewCell.xib in Resources */, + F8154D7725181D4100BFB42C /* HeadlineSuccessResponse.json in Resources */, F89B0223250D446200B41293 /* LaunchScreen.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -232,24 +830,199 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + F888006B2517A8EE008DCC54 /* HeadlineSuccessResponse.json in Resources */, + F858998425177D6200A6BA2A /* Articles.json in Resources */, + F888006D2517AA1F008DCC54 /* HeadlineFailureResponse.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - F89B0232250D446200B41293 /* Resources */ = { - isa = PBXResourcesBuildPhase; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 2A72020948C2D75438B5DA75 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-DutchNews/Pods-DutchNews-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-DutchNews/Pods-DutchNews-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-DutchNews/Pods-DutchNews-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + B48216F6A905881BBE665BFD /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-DutchNewsTests/Pods-DutchNewsTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-DutchNewsTests/Pods-DutchNewsTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-DutchNewsTests/Pods-DutchNewsTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; -/* End PBXResourcesBuildPhase section */ + ED77C5D597B0DE2AE4BC3C93 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-DutchNews-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + EF9A2F60853CCD39B6A80215 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-DutchNewsTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F8E5C105251915CE0083D2B1 /* Swift Lint Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Swift Lint Run Script"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nif which \"${PODS_ROOT}/SwiftLint/swiftlint\" >/dev/null; then\n \"${PODS_ROOT}/SwiftLint/swiftlint\" autocorrect\n \"${PODS_ROOT}/SwiftLint/swiftlint\"\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n\n"; + }; +/* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ F89B0216250D446000B41293 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F8E5C12F25191DA60083D2B1 /* UIColor+Extension.swift in Sources */, + F85E318C251B8462002753AC /* ArticlesPageViewController.swift in Sources */, + F85899882517894B00A6BA2A /* HeadlinesArticleRemoteRepository.swift in Sources */, + F8E5C0F32518D8AC0083D2B1 /* ArticleViewModel.swift in Sources */, + F8E5C0ED2518833B0083D2B1 /* ArticlesViewModel.swift in Sources */, + F8C83462251C04020051A0FD /* Storable.swift in Sources */, + F81572C5251D4BEB009DBFD7 /* UIStoryboard+Additional.swift in Sources */, + F8154D6F25180B0200BFB42C /* UIView+Nib.swift in Sources */, + F85E319E251B8D61002753AC /* RxWKUIDelegateProxy.swift in Sources */, + F858997B25176DC800A6BA2A /* Article.swift in Sources */, + F85E319D251B8D61002753AC /* Rx+WebKit.swift in Sources */, + F8C83460251C04020051A0FD /* CodableDataManager.swift in Sources */, + F8866A3F251EABE8008AF310 /* HeadlineSearchViewController.swift in Sources */, + F865F7622516A6C1001FD067 /* APIServerResponseError.swift in Sources */, + F884C0FD251D2CAE0078E88B /* ArticlesUseCases.swift in Sources */, + F8E5C11E25191D5E0083D2B1 /* Date+Convertor.swift in Sources */, + F865F7602516A643001FD067 /* APIServerResponseStatus.swift in Sources */, + F8E5C0FA2518E8100083D2B1 /* HeadlinesFetchingUseCase.swift in Sources */, + F884C0FB251D24000078E88B /* HeadlinesUseCases.swift in Sources */, + F8E5C12125191D5E0083D2B1 /* Bundle+Extensions.swift in Sources */, + F8154D522517EBC700BFB42C /* HeadlinesViewController+MagazineLayout.swift in Sources */, + F8DE79E32515904400A6C2D5 /* NetworkService.swift in Sources */, + F8C83468251C22C80051A0FD /* DispatchQueue+Additonals.swift in Sources */, + F8154D752518156000BFB42C /* HeadlinesViewController+DataSource.swift in Sources */, + F8D58B83251CFC1E00B426AC /* HeadlinesArticleLocalRepository.swift in Sources */, + F85E319F251B8D61002753AC /* WKNavigationDelegateEvents+Rx.swift in Sources */, + F85E31A1251B8D61002753AC /* RxWKUserContentController.swift in Sources */, + F81572C3251D4AB2009DBFD7 /* ViewControllerFactory.swift in Sources */, + F85E31A0251B8D61002753AC /* RxWKUIDelegateEvents+Rx.swift in Sources */, + F8154D6C25180A9000BFB42C /* HeadlineBaseCollectionViewCell.swift in Sources */, + F865F76B2516C08C001FD067 /* NetworkValidResponse.swift in Sources */, + F841DD0A25195415006E7E90 /* GradientView.swift in Sources */, + F8E5C12325191D5E0083D2B1 /* URL+ApplicationPath.swift in Sources */, + F8154D612518011500BFB42C /* MainArticleCollectionViewCell.swift in Sources */, + F8E5C0F1251884A20083D2B1 /* HeadlineSearchViewModel.swift in Sources */, + F81572BF251D487E009DBFD7 /* ScreenEnum.swift in Sources */, + F8E5C12D25191DA60083D2B1 /* UIViewController+StoryboardName.swift in Sources */, + F8154D7125180C3900BFB42C /* ArticleWebContainerCollectionViewCell.swift in Sources */, + F8E5C12A25191DA60083D2B1 /* UIImage+Additionals.swift in Sources */, + F8866A42251EC3BC008AF310 /* HeadlineSearchTableViewCell.swift in Sources */, + F8E5C12B25191DA60083D2B1 /* UINavigationBar+Additionals.swift in Sources */, + F865F75025168F05001FD067 /* AppConfig.swift in Sources */, + F81572BD251D4839009DBFD7 /* Screen.swift in Sources */, + F8E5C0FE25190CDD0083D2B1 /* HeadlineCellViewModel.swift in Sources */, + F865F7642516A9D5001FD067 /* APIServerResponse.swift in Sources */, + F8E5C13325193A290083D2B1 /* String+Localization.swift in Sources */, + F841DD0C25195429006E7E90 /* LinearGradientView.swift in Sources */, + F8E5C0F52518DD3E0083D2B1 /* ViewModelState.swift in Sources */, + F897BD3C251D5C7E003822EA /* ViewModelViewControllerFactory.swift in Sources */, + F8E5C0FC2518E9150083D2B1 /* HeadlinesSearchingUseCases.swift in Sources */, + F8154D572517F03600BFB42C /* ArticleHeadlineLayoutConfiguration.swift in Sources */, + F8E5C0EF2518848D0083D2B1 /* HeadlinesViewModel.swift in Sources */, + F8154D4E2517D28700BFB42C /* HeadlinesViewController.swift in Sources */, + F8154D652518012F00BFB42C /* ArticleRowCollectionViewCell.swift in Sources */, + F8154D792518207B00BFB42C /* String+HTML.swift in Sources */, + F85E318E251B8484002753AC /* ArticleDetailViewController.swift in Sources */, + F8E5C11F25191D5E0083D2B1 /* Collection+Additionals.swift in Sources */, + F81572A2251D37F7009DBFD7 /* ArticleDetailViewModel.swift in Sources */, + F8C83463251C04020051A0FD /* Storage.swift in Sources */, + F8E5C0EB251882560083D2B1 /* UIViewController+AlertableView.swift in Sources */, + F81572A0251D3254009DBFD7 /* ArticlesPageViewModel.swift in Sources */, + F81572A4251D3B39009DBFD7 /* ArticlesPageUseCase.swift in Sources */, + F85E3192251B85E4002753AC /* ArticleDetailHeaderView.swift in Sources */, + F8154D552517EC0D00BFB42C /* HeadlineLayoutConfiguration.swift in Sources */, + F85E31A2251B8D61002753AC /* RxWKNavigationDelegateProxy.swift in Sources */, + F8E5C0E9251881C80083D2B1 /* AlertableView.swift in Sources */, + F865F74C251686D2001FD067 /* Authenticator.swift in Sources */, + F8E5C12E25191DA60083D2B1 /* UIImageView+SDWebImage.swift in Sources */, + F8589986251784B200A6BA2A /* ArticleRepository.swift in Sources */, + F8DE79E5251594D700A6C2D5 /* APIClientService.swift in Sources */, + F8154D692518016800BFB42C /* HalfWidthArticleCollectionViewCell.swift in Sources */, + F8E5C12025191D5E0083D2B1 /* Date+TimeAgo.swift in Sources */, + F865F76D2516C826001FD067 /* DefaultAPIValidResponse.swift in Sources */, + F8E5C1312519250E0083D2B1 /* Logger.swift in Sources */, + F897BD3E251D740C003822EA /* RxHeadlinesDataSource.swift in Sources */, + F858997D25176E8F00A6BA2A /* ArticleSource.swift in Sources */, + F8E5C135251943CF0083D2B1 /* AppDIContainer.swift in Sources */, + F8E5C12225191D5E0083D2B1 /* String+EmptyChecking.swift in Sources */, F89B021E250D446000B41293 /* AppDelegate.swift in Sources */, + F8E5C12C25191DA60083D2B1 /* UILabel+Localization.swift in Sources */, + F815729D251D3077009DBFD7 /* ArticleDetailsPageViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -257,15 +1030,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F85899902517910700A6BA2A /* HeadlinesArticleRemoteRepositoryTests.swift in Sources */, + F85899822517738C00A6BA2A /* ModelsDataFactory.swift in Sources */, + F8C83466251C1F480051A0FD /* CodableDataManagerTests.swift in Sources */, + F865F7662516AB66001FD067 /* APIServerResponseTests.swift in Sources */, + F88800692517A423008DCC54 /* RepositoryDependenciesFactory.swift in Sources */, F89B022E250D446200B41293 /* DutchNewsTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - F89B0230250D446200B41293 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F89B0239250D446200B41293 /* DutchNewsUITests.swift in Sources */, + F82C8F0125163898002B27B3 /* NetworkMockingDataFactory.swift in Sources */, + F82C8EFF25163073002B27B3 /* NetworkMocking.swift in Sources */, + F865F74E251687E4001FD067 /* APIAuthenticatorTests.swift in Sources */, + F8589980251772CC00A6BA2A /* ModelTests.swift in Sources */, + F82C8EFD2516304D002B27B3 /* APIClientServiceTests.swift in Sources */, + F8D58B85251D04F900B426AC /* HeadlinesArticleLocalRepositoryTests.swift in Sources */, + F858998D2517909A00A6BA2A /* MockArticleValidResponse.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -277,14 +1054,18 @@ target = F89B0219250D446000B41293 /* DutchNews */; targetProxy = F89B022A250D446200B41293 /* PBXContainerItemProxy */; }; - F89B0236250D446200B41293 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = F89B0219250D446000B41293 /* DutchNews */; - targetProxy = F89B0235250D446200B41293 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + F8505757251E066F00257884 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + F8505758251E066F00257884 /* en */, + F8505759251E066F00257884 /* nl-NL */, + ); + name = Localizable.strings; + sourceTree = ""; + }; F89B0221250D446200B41293 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -293,6 +1074,14 @@ name = LaunchScreen.storyboard; sourceTree = ""; }; + F8F14C72250D719800C24FF5 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + F8F14C73250D719800C24FF5 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ @@ -300,6 +1089,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -360,6 +1150,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -412,8 +1203,10 @@ }; F89B023E250D446200B41293 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = CE0BB7F85E1175162F017DD0 /* Pods-DutchNews.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 638B4QA28J; INFOPLIST_FILE = DutchNews/Info.plist; @@ -424,6 +1217,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.ifarshad.DutchNews; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -431,8 +1225,10 @@ }; F89B023F250D446200B41293 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9F5E3588EBC310FDA84D4BC9 /* Pods-DutchNews.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 638B4QA28J; INFOPLIST_FILE = DutchNews/Info.plist; @@ -443,6 +1239,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.ifarshad.DutchNews; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -450,13 +1247,14 @@ }; F89B0241250D446200B41293 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5BB041D0AA6B599D4F5E7D81 /* Pods-DutchNewsTests.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 638B4QA28J; INFOPLIST_FILE = DutchNewsTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -472,13 +1270,14 @@ }; F89B0242250D446200B41293 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = C8E2BC6DABFC66D3F898B924 /* Pods-DutchNewsTests.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 638B4QA28J; INFOPLIST_FILE = DutchNewsTests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.2; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -492,46 +1291,6 @@ }; name = Release; }; - F89B0244250D446200B41293 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 638B4QA28J; - INFOPLIST_FILE = DutchNewsUITests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.ifarshad.DutchNewsUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = DutchNews; - }; - name = Debug; - }; - F89B0245250D446200B41293 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 638B4QA28J; - INFOPLIST_FILE = DutchNewsUITests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.ifarshad.DutchNewsUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = DutchNews; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -562,15 +1321,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F89B0243250D446200B41293 /* Build configuration list for PBXNativeTarget "DutchNewsUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F89B0244250D446200B41293 /* Debug */, - F89B0245250D446200B41293 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ }; rootObject = F89B0212250D446000B41293 /* Project object */; diff --git a/DutchNews.xcodeproj/xcuserdata/farshad.xcuserdatad/xcschemes/xcschememanagement.plist b/DutchNews.xcodeproj/xcuserdata/farshad.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 11f2ed6..0000000 --- a/DutchNews.xcodeproj/xcuserdata/farshad.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - DutchNews.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/DutchNews.xcworkspace/contents.xcworkspacedata b/DutchNews.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..010f476 --- /dev/null +++ b/DutchNews.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/DutchNews/AppConfig.swift b/DutchNews/AppConfig.swift new file mode 100644 index 0000000..c2336f9 --- /dev/null +++ b/DutchNews/AppConfig.swift @@ -0,0 +1,14 @@ +// +// AppConfig.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/19/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +struct AppConfig { + static let APIKey = "56450901b0134dcbb5627035b12fca99" + static let BaseURL = URL(string: "https://newsapi.org/v2/")! +} diff --git a/DutchNews/AppDIContainer.swift b/DutchNews/AppDIContainer.swift new file mode 100644 index 0000000..a3594eb --- /dev/null +++ b/DutchNews/AppDIContainer.swift @@ -0,0 +1,107 @@ +// +// AppDIContainer.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import Alamofire + +struct AppDIContainer { + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Data Layers DI Container + // MARK: - + //////////////////////////////////////////////////////////////// + + static let authenticator: RequestInterceptor = { + return APIAuthenticator(token: AppConfig.APIKey) + }() + + static let authorizedNetworkService: NetworkServiceInterceptable = { + let session = Session() + session.sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + let apiClient = APIClientService(baseURL: AppConfig.BaseURL, session: session, decoder: decoder) + apiClient.addingRequest(interceptor: authenticator) + return apiClient + }() + + static let networkService: NetworkServiceInterceptable = { + let session = Session() + session.sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + return APIClientService(baseURL: AppConfig.BaseURL, session: session, decoder: decoder) + }() + + static let decoder: DataDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + static let storage: Storage = { + return CodableDataManager.default + }() + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Repository DI Container + // MARK: - + //////////////////////////////////////////////////////////////// + + static var headlineArticleRepository: ArticleRepository { + return HeadlinesArticleRemoteRepository(networkService: authorizedNetworkService, + authentictor: authenticator, + validator: DefaultAPIValidResponse()) + } + + static var headlineLocalArticleRepository: ArticleRepository { + return HeadlinesArticleLocalRepository(storage: storage) + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Use Cases DI Container + // MARK: - + //////////////////////////////////////////////////////////////// + + static var headlineFetchingUseCase: HeadlinesUseCases { + return HeadlinesFetchingUseCase(repository: headlineArticleRepository) + + } + + static var articlesPageUseCase: ArticlesUseCase { + return ArticlesPageUseCase(repository: headlineArticleRepository) + } + + static var headlineSearchUseCase: HeadlinesUseCases { + return HeadlinesSearchingUseCases(repository: headlineArticleRepository) + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: ViewModels DI Container + // MARK: - + //////////////////////////////////////////////////////////////// + + static var headlinesViewModel: ArticlesViewModel { + return HeadlinesViewModel(useCase: headlineFetchingUseCase) + } + + static var articlePagesViewModel: ArticlesPageViewModel { + return ArticleDetailsPageViewModel(useCase: articlesPageUseCase) + } + + static var headlineSearchViewModel: ArticlesSearchViewModel { + return HeadlineSearchViewModel(useCase: headlineSearchUseCase) + } + + static let viewModelViewControllerFactory: ViewControllerFactory = { + let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: AppDelegate.self)) + return ViewModelViewControllerFactory(storyboard: storyboard) + }() + +} diff --git a/DutchNews/AppDelegate.swift b/DutchNews/AppDelegate.swift index 4640717..9898e97 100644 --- a/DutchNews/AppDelegate.swift +++ b/DutchNews/AppDelegate.swift @@ -10,37 +10,45 @@ import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? - - + + @IBOutlet var window: UIWindow? + + private var viewControllerFactory: ViewControllerFactory = AppDIContainer.viewModelViewControllerFactory + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. - window = UIWindow() - window?.backgroundColor = UIColor.lightGray - window?.rootViewController = UIViewController() + _ = Logger() + + window = UIWindow(forAutoLayout: ()) + do { + let vc = try viewControllerFactory.makeHeadlinesViewController() + let root = viewControllerFactory.makeRootViewController() + root?.setViewControllers([vc], animated: true) + window?.rootViewController = root + }catch { + window?.rootViewController = UIViewController() + } + window?.makeKeyAndVisible() - + return true } - + func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. } - + func applicationDidEnterBackground(_ application: UIApplication) { // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. } - + func applicationWillEnterForeground(_ application: UIApplication) { // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. } - + func applicationDidBecomeActive(_ application: UIApplication) { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. } - - + } - diff --git a/DutchNews/Base.lproj/LaunchScreen.storyboard b/DutchNews/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e932..0000000 --- a/DutchNews/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DutchNews/Classes/Data Layers/Authenticator/Authenticator.swift b/DutchNews/Classes/Data Layers/Authenticator/Authenticator.swift new file mode 100644 index 0000000..3c775b3 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Authenticator/Authenticator.swift @@ -0,0 +1,28 @@ +// +// Authenticator.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/19/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import Alamofire + +struct APIAuthenticator: RequestInterceptor { + + let token: String + + init(token: String) { + self.token = token + } + + func adapt(_ urlRequest: URLRequest, + for session: Session, + completion: @escaping (Result) -> Void) { + var request = urlRequest + request.headers.add(HTTPHeader.authorization(bearerToken: self.token)) + completion(Result { request }) + } + +} diff --git a/DutchNews/Classes/Data Layers/Networking/APIClientService.swift b/DutchNews/Classes/Data Layers/Networking/APIClientService.swift new file mode 100644 index 0000000..0040da5 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Networking/APIClientService.swift @@ -0,0 +1,273 @@ +// +// APIClientService.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/19/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import RxSwift +import RxAlamofire +import Alamofire + +private let queueName = "com.ifarshad.DutchNews.networking.response" + +fileprivate extension DispatchQueue { + + /// Default queue for handling response + static let networkResponseQueue = DispatchQueue(label: queueName, + qos: .background, + attributes: .concurrent, + autoreleaseFrequency: .workItem) +} + +/// <#Description#> +final class APIClientService: NetworkServiceInterceptable { + + typealias SessionManager = Session + + /// <#Description#> + let baseURL: URL + + /// <#Description#> + private(set) var session: SessionManager + + /// <#Description#> + let workQueue: DispatchQueue + + private var interceptor: RequestInterceptor? + + let decoder: DataDecoder + + /// <#Description#> + /// - Parameters: + /// - baseURL: <#baseURL description#> + /// - session: <#session description#> + /// - queue: <#queue description#> + /// - decoder: <#decoder description#> + init(baseURL: URL, + session: SessionManager = .default, + queue: DispatchQueue = .networkResponseQueue, + decoder: DataDecoder = JSONDecoder()) { + self.baseURL = baseURL + self.session = session + self.workQueue = queue + self.decoder = decoder + } + + func addingRequest(interceptor: RequestInterceptor) { + let locker = NSLock() + locker.lock() + defer { + locker.unlock() + } + + self.interceptor = interceptor + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Private Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + /// <#Description#> + /// - Parameter endpoint: <#endpoint description#> + private func attachBaseURL(into endpoint: URLConvertible) throws -> URLConvertible { + + let endPoint = try endpoint.asURL().absoluteString + + guard let joinedURL = URL(string: endPoint, relativeTo: baseURL) else { + throw AFError.invalidURL(url: "\(baseURL.absoluteString)/\(endPoint)") + } + + return joinedURL + } + + /// <#Description#> + /// - Parameters: + /// - dataRequest: <#dataRequest description#> + /// - decoder: <#decoder description#> + private func map (dataRequest: DataRequest, decoder: DataDecoder) -> Observable> { + + return dataRequest.rx.responseResult(queue: workQueue, responseSerializer: DecodableResponseSerializer(decoder: decoder)).map({ $1 }) + .map { value in + return Result { value } + }.catchError { (error) -> Observable> in + .just(.failure(error)) + } + } + + private func validate(dataRequest: DataRequest, validator: NetworkValidResponse?) -> DataRequest { + + guard let validator = validator else { + return dataRequest.validate() + } + + return dataRequest.validate(statusCode: validator.statusCodes) + .validate(contentType: validator.contentTypes) + + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Abstract Implementation + // MARK: - + //////////////////////////////////////////////////////////////// + + /// <#Description#> + /// - Parameters: + /// - endpoint: <#endpoint description#> + /// - parameters: <#parameters description#> + /// - method: <#method description#> + /// - headers: <#headers description#> + /// - completion: <#completion description#> + func executeRequest(endpoint: EndPoint, + parameters: Parameters = [:], + method: HTTPMethod = .get, + headers: NetworkHeadersType = [:], + validator: NetworkValidResponse? = nil, + completion: @escaping ResponseCompletion) -> DataRequest? { + do { + + let url = try attachBaseURL(into: endpoint) + let headers = HTTPHeaders(headers) + let dataTask = session.request(url, + method: method, + parameters: parameters, + encoding: URLEncoding.default, + headers: headers, + interceptor: interceptor) + dataTask.responseString { (result) in + + Logger.debugLog(result.debugDescription,tag: "Networking") + } + return validate(dataRequest: dataTask, validator: validator) + .responseDecodable(queue: workQueue, decoder: decoder) { (response: DataResponse ) in + let result = response.result.flatMapError { (error) -> Result in + return .failure(error) + } + + completion(result) + } + + }catch let error { + completion(.failure(error)) + return nil + } + } + + /// <#Description#> + /// - Parameters: + /// - endpoint: <#endpoint description#> + /// - method: <#method description#> + /// - parameter: <#parameter description#> + /// - headers: <#headers description#> + /// - completion: <#completion description#> + func executeRequest(endpoint: EndPoint, + method: HTTPMethod = .get, + parameter: P, + headers: NetworkHeadersType = [:], + validator: NetworkValidResponse? = nil , + completion: @escaping ResponseCompletion) -> DataRequest? { + do { + + let url = try attachBaseURL(into: endpoint) + let dataTask = session.request(url, + method: method, + parameters: parameter, + encoder: JSONParameterEncoder.prettyPrinted, + headers: HTTPHeaders(headers), + interceptor: interceptor) + dataTask.responseString { (result) in + Logger.debugLog(result.debugDescription,tag: "Networking") + } + + return validate(dataRequest: dataTask, validator: validator) + + .responseDecodable(queue: workQueue, decoder: decoder) { (response: DataResponse ) in + let result = response.result.flatMapError { (error) -> Result in + return .failure(error) + } + + completion(result) + } + + }catch let error { + completion(.failure(error)) + return nil + } + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: RxSwift Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + /// <#Description#> + /// - Parameters: + /// - endpoint: <#endpoint description#> + /// - parameters: <#parameters description#> + /// - method: <#method description#> + /// - headers: <#headers description#> + func executeRequest(endpoint: EndPoint, + parameters: Parameters = [:], + method: HTTPMethod = .get, + headers: NetworkHeadersType = [:], + validator: NetworkValidResponse? = nil) -> Observable> { + do { + + let url = try attachBaseURL(into: endpoint) + var dataTask = session.request(url, + method: method, + parameters: parameters, + encoding: URLEncoding.default, + headers: HTTPHeaders(headers), + interceptor: interceptor) + dataTask = validate(dataRequest: dataTask, validator: validator) + dataTask.responseString { (result) in + Logger.debugLog(result.debugDescription,tag: "Networking") + } + + return map(dataRequest: dataTask, decoder: decoder) + + }catch let error { + return .just(.failure(error)) + } + } + + /// <#Description#> + /// - Parameters: + /// - endpoint: <#endpoint description#> + /// - method: <#method description#> + /// - parameter: <#parameter description#> + /// - headers: <#headers description#> + func executeRequest(endpoint: EndPoint, + method: HTTPMethod = .get, + parameter: P, + headers: NetworkHeadersType = [:], + validator: NetworkValidResponse? = nil) -> Observable> { + do { + + let url = try attachBaseURL(into: endpoint) + var dataTask = session.request(url, + method: method, + parameters: parameter, + encoder: JSONParameterEncoder.prettyPrinted, + headers: HTTPHeaders(headers), + interceptor: interceptor) + dataTask = validate(dataRequest: dataTask, validator: validator) + + dataTask.responseString { (result) in + Logger.debugLog(result.debugDescription,tag: "Networking") + } + + return map(dataRequest: dataTask, decoder: decoder) + + }catch let error { + return .just(.failure(error)) + } + } + +} diff --git a/DutchNews/Classes/Data Layers/Networking/NetworkService.swift b/DutchNews/Classes/Data Layers/Networking/NetworkService.swift new file mode 100644 index 0000000..fe6f0ea --- /dev/null +++ b/DutchNews/Classes/Data Layers/Networking/NetworkService.swift @@ -0,0 +1,130 @@ +// +// NetworkService.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/19/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import Alamofire + +/// `NetworkService` Abstract +protocol NetworkService { + + /// <#Description#> + typealias NetworkHeadersType = [String: String] + + /// <#Description#> + typealias NetworkParametersType = Parameters + + /// <#Description#> + typealias ResponseResult = Swift.Result + + typealias ResponseCompletion = (ResponseResult) -> Void + + typealias Parameters = [String: Any] + + typealias EndPoint = URLConvertible + + /// <#Description#> + /// - Parameters: + /// - endpoint: <#endpoint description#> + /// - parameters: <#parameters description#> + /// - method: <#method description#> + /// - headers: <#headers description#> + /// - completion: <#completion description#> + func executeRequest(endpoint: EndPoint, + parameters: Parameters, + method: HTTPMethod, + headers: NetworkHeadersType, + validator: NetworkValidResponse?, + completion: @escaping ResponseCompletion) -> DataRequest? + + /// <#Description#> + /// - Parameters: + /// - endpoint: <#endpoint description#> + /// - method: <#method description#> + /// - parameter: <#parameter description#> + /// - headers: <#headers description#> + /// - completion: <#completion description#> + func executeRequest(endpoint: EndPoint, + method: HTTPMethod, + parameter: P, headers: NetworkHeadersType, + validator: NetworkValidResponse?, + completion: @escaping ResponseCompletion) -> DataRequest? + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: RxSwift Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + /// <#Description#> + /// - Parameters: + /// - endpoint: <#endpoint description#> + /// - parameters: <#parameters description#> + /// - method: <#method description#> + /// - headers: <#headers description#> + func executeRequest(endpoint: EndPoint, + parameters: Parameters, + method: HTTPMethod, + headers: NetworkHeadersType, + validator: NetworkValidResponse?) -> Observable> + + /// <#Description#> + /// - Parameters: + /// - endpoint: <#endpoint description#> + /// - method: <#method description#> + /// - parameter: <#parameter description#> + /// - headers: <#headers description#> + func executeRequest(endpoint: EndPoint, + method: HTTPMethod, + parameter: P, headers: NetworkHeadersType, + validator: NetworkValidResponse?) -> Observable> + +} + +/// <#Description#> +protocol NetworkServiceInterceptable: NetworkService { + + /// <#Description#> + /// - Parameter interceptor: <#interceptor description#> + func addingRequest(interceptor: RequestInterceptor) +} + +extension NetworkService { + + func executeRequest(endpoint: EndPoint, + parameters: Parameters, + method: HTTPMethod, + headers: NetworkHeadersType, + validator: NetworkValidResponse? = nil, + completion: @escaping ResponseCompletion) -> DataRequest? { + return nil + } + + func executeRequest(endpoint: EndPoint, + method: HTTPMethod, + parameter: P, headers: NetworkHeadersType, + validator: NetworkValidResponse? = nil, + completion: @escaping ResponseCompletion ) -> DataRequest? { + return nil + } + + func executeRequest(endpoint: EndPoint, + parameters: Parameters, + method: HTTPMethod, + headers: NetworkHeadersType, + validator: NetworkValidResponse? = nil) -> Observable> { + return .empty() + } + + func executeRequest(endpoint: EndPoint, + method: HTTPMethod, + parameter: P, headers: NetworkHeadersType, + validator: NetworkValidResponse? = nil) -> Observable> { + return .empty() + } +} diff --git a/DutchNews/Classes/Data Layers/Networking/NetworkValidResponse.swift b/DutchNews/Classes/Data Layers/Networking/NetworkValidResponse.swift new file mode 100644 index 0000000..cb8ec47 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Networking/NetworkValidResponse.swift @@ -0,0 +1,22 @@ +// +// NetworkValidResponse.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import Alamofire + +/// NetworkValidResponse Abstract +protocol NetworkValidResponse { + + var statusCodes: Set { get } + var contentTypes: [String] { get } +} + +extension NetworkValidResponse { + var statusCodes: Set { Set(200..<300) } + var contentTypes: [String] { ["*/*" ] } +} diff --git a/DutchNews/Classes/Data Layers/Networking/Response/APIServerResponse.swift b/DutchNews/Classes/Data Layers/Networking/Response/APIServerResponse.swift new file mode 100644 index 0000000..0a8a0b5 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Networking/Response/APIServerResponse.swift @@ -0,0 +1,75 @@ +// +// APIServerResponse.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +struct APIServerResponse where T: Decodable { + + var status: APIServerResponseStatus = .success + var message: String? + var data: T? + + enum CodingKeys: String, CodingKey { + case status = "status" + case code = "code" + case message = "message" + case data = "articles" + } + + init(status: APIServerResponseStatus) { + self.status = status + } + +} + +extension APIServerResponse: Decodable { + + public init(from decoder: Decoder) throws { + + let values = try decoder.container(keyedBy: CodingKeys.self) + self.status = try values.decode(APIServerResponseStatus.self, forKey: .status) + + do { + self.message = try values.decodeIfPresent(String.self, forKey: .message) + }catch { + self.message = nil + } + + guard status == .success else { + + if let errorType = try? values.decodeIfPresent(String.self, forKey: .code) { + + if let message = try? values.decodeIfPresent(String.self, forKey: .message) { + throw APIServerResponseError.message("\(errorType)", message) + }else { + throw APIServerResponseError.code("Code: \(errorType)") + } + + }else { + throw APIServerResponseError.unknown + } + + } + + do { + self.data = try values.decodeIfPresent(T.self, forKey: .data) + }catch { + self.data = nil + throw APIServerResponseError.code("\(error)") + } + + } + +} + +extension APIServerResponse: CustomDebugStringConvertible { + + var debugDescription: String { + return "[Server-Response] status = \(status) message= \(message ?? "no message") error = empty data = \(data)" + } +} diff --git a/DutchNews/Classes/Data Layers/Networking/Response/APIServerResponseError.swift b/DutchNews/Classes/Data Layers/Networking/Response/APIServerResponseError.swift new file mode 100644 index 0000000..25cdcd3 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Networking/Response/APIServerResponseError.swift @@ -0,0 +1,52 @@ +// +// APIServerResponseError.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +enum APIServerResponseError: Error { + case code(String) + case message(String, String) + case unknown +} + +extension APIServerResponseError: LocalizedError { + + var errorDescription: String? { + return self.errorDes + } + + fileprivate var errorDes: String { + return self.message + } +} + +extension APIServerResponseError { + + var type: String { + switch self { + case .code(let type), + .message(let type, _): + return type + case .unknown : + return "UNKNOWN_ERROR" + } + } + + var message: String { + + switch self { + case .code(let value): + return value + case .message(_ , let msg): + return msg + case .unknown : + return "Unknown Error Occured. Please Contact Support" + } + } + +} diff --git a/DutchNews/Classes/Data Layers/Networking/Response/APIServerResponseStatus.swift b/DutchNews/Classes/Data Layers/Networking/Response/APIServerResponseStatus.swift new file mode 100644 index 0000000..1bdce42 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Networking/Response/APIServerResponseStatus.swift @@ -0,0 +1,16 @@ +// +// APIServerResponseStatus.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +enum APIServerResponseStatus: String, Codable { + + case success = "ok" + case failure = "error" + +} diff --git a/DutchNews/Classes/Data Layers/Networking/Response/DefaultAPIValidResponse.swift b/DutchNews/Classes/Data Layers/Networking/Response/DefaultAPIValidResponse.swift new file mode 100644 index 0000000..1f87707 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Networking/Response/DefaultAPIValidResponse.swift @@ -0,0 +1,14 @@ +// +// DefaultAPIValidResponse.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +struct DefaultAPIValidResponse: NetworkValidResponse { + var statusCodes: Set { Set((200..<300).map { $0 } + [400, 422, 429]) } + var contentTypes: [String] { ["application/json; charset=utf-8"] } +} diff --git a/DutchNews/Classes/Data Layers/Persistence/Abstracts/Storable.swift b/DutchNews/Classes/Data Layers/Persistence/Abstracts/Storable.swift new file mode 100644 index 0000000..04c9b86 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Persistence/Abstracts/Storable.swift @@ -0,0 +1,17 @@ +// +// Storable.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +/// Abstract `Storable` +/// The purpose of this abstract is to store and retreive Entities in Presistence Layer. +protocol Storable: Codable { + + func primaryKeyValue() -> String + +} diff --git a/DutchNews/Classes/Data Layers/Persistence/Abstracts/Storage.swift b/DutchNews/Classes/Data Layers/Persistence/Abstracts/Storage.swift new file mode 100644 index 0000000..a36048d --- /dev/null +++ b/DutchNews/Classes/Data Layers/Persistence/Abstracts/Storage.swift @@ -0,0 +1,91 @@ +// +// Storage.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +/// CreatableStorage Abstract +protocol CreatableStorage { + + /// Create a new object with default values + /// + /// - Parameters: + /// - model: the type of object + /// - completion: a completion block that return an object that is conformed to the `Storable` protocol. + /// - Throws: throw an error object which describe more about occured error. + func create(_ model: T.Type, completion:@escaping (T) -> Void) throws +} + +/// SavableStorage Abstract +protocol SavableStorage { + + /// Save an object that is conformed to the `Storable` protocol + /// + /// - Parameter object: an object that is conformed to the `Storable`. + /// - Throws: throw an error when saving operation has been failed. + func save(object: T) throws +} + +/// UpdatableStorage Abstract +protocol UpdatableStorage { + + /// Update the object which is conformed to the `Storable` protocol. + /// + /// - Parameter object: the `Storable` conformed object. + /// - Throws: throw the proper error when update failed. + func update(object: T) throws +} + +/// DeletableStorage Abstract +protocol DeletableStorage { + + /// Delete an object that is conformed to the `Storable` protocol + /// + /// - Parameter object: <#object description#> + /// - Throws: throw an error when operation failed. + func delete(object: Storable) throws + + /// Delete an object that is conformed to the `Storable` protocol + /// + /// - Parameter object: the storable object + /// - Throws: throw an error when operation failed. + func delete(object: [Storable]) throws + + /// Delete all objects that are conformed to the `Storable` protocol + /// + /// - Parameter model: <#model description#> + /// - Throws: throw an error when operation failed. + func deleteAll (_ model: T.Type) throws +} + +/// Abstract `DeletableStorage` +extension DeletableStorage { + + func delete(object: [Storable]) throws { + // make delete object method to optional. + } +} + +/// Abstract `FetchableStorage` +protocol FetchableStorage { + + /// The `Sort` type contain `key` and `ascending` object + typealias Sort = (key: String, ascending: Bool) + + /// Fetch and return a list of objects that are conformed to the `Storable` protocol. + /// + /// - Parameters: + /// - type: <#type description#> + /// - predicate: The predicate object of 'NSPredicate' + /// - sort: An sort value which the result sorts base on. + /// - completion: The completion block return a list of objects that are conformed to the `Storable` protocol + /// - Throws: <#throws value description#> + func fetch (type: T.Type, predicate: NSPredicate?, sort: Sort?, completion:@escaping ([T]) -> Void) throws +} + +/// The `Storage` Type combine all abstracts in this file. +typealias Storage = CreatableStorage & SavableStorage & UpdatableStorage & DeletableStorage & FetchableStorage diff --git a/DutchNews/Classes/Data Layers/Persistence/Database/CodableDataManager.swift b/DutchNews/Classes/Data Layers/Persistence/Database/CodableDataManager.swift new file mode 100644 index 0000000..3785042 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Persistence/Database/CodableDataManager.swift @@ -0,0 +1,158 @@ +// +// RealmDataManager.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import CryptoSwift + +/// The `CodableDataManager` class extended and implemented `Storage` abstract. +/// The Default DataManager of this application. +class CodableDataManager: Storage { + + /// Rhe default instace of `CodableDataManager` + static let `default` = CodableDataManager(fileProvider: .default) + + /// The Stored fileURL object. + private let fileURL: URL + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + /// Constructor + /// + /// - Parameter fileProvider: the file provider options + init(fileProvider: FileProvider) { + + self.fileURL = fileProvider.url + + decoder.dateDecodingStrategy = .iso8601 + decoder.dataDecodingStrategy = .base64 + + encoder.dateEncodingStrategy = .iso8601 + encoder.dataEncodingStrategy = .base64 + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + + } + + deinit { + + } + + // MARK: - Storage Implementation methods + + func create(_ model: T.Type, completion: @escaping (T) -> Void) throws where T: Storable { + fatalError("not implemented for demo") + } + + func save(object: T) throws { + + let data = try encoder.encode(object) + try data.write(to: makeFileURL(for: object),options: [.withoutOverwriting]) + } + + func update (object: T) throws { + let data = try encoder.encode(object) + try data.write(to: makeFileURL(for: object)) + } + + func delete(object: Storable) throws { + fatalError("not implemented for demo") + } + + func deleteAll(_ model: T.Type) throws where T: Storable { + fatalError("not implemented for demo") + } + + func fetch(type: T.Type, predicate: NSPredicate?, sort: Sort?, completion: @escaping ([T]) -> Void) throws where T: Storable { + + let url = self.fileURL + perform(onQueue: .global(qos: .utility), block: {[weak self] in + + let fileManager = FileManager.default + guard let list = fileManager.enumerator(at: url, + includingPropertiesForKeys: [], + options: [.skipsHiddenFiles], + errorHandler: { (url,error) -> Bool in + Logger.errorLog("Error \(error) for url \(url)", tag: "FileManager") + return true + }) else { + self?.perform { + completion([]) + } + return + } + + let result = list.compactMap({ $0 as? URL }) + + var objects = [T]() + for path in result { + + guard let data = try? Data(contentsOf: path), let objc = try? self?.decoder.decode(type, from: data) else { + continue + } + + objects.append(objc) + } + + let output = objects.filter({ predicate?.evaluate(with: $0) ?? true }) + + self?.perform { + completion(output) + } + }) + + } + + private func makeFileURL (for obj: T) -> URL { + + let fileName = "\(obj.primaryKeyValue().md5())-\(String(describing: T.self)).json" + let url = fileURL.appendingPathComponent(fileName) + return url + } + + private func perform(onQueue queue: DispatchQueue = .main, + block workItem:@escaping () -> Void) { + + guard DispatchQueue.current !== queue else { + workItem() + return + } + + queue.async(execute: workItem) + + } + +} + +extension CodableDataManager { + + enum FileProvider { + case `default` + case custom(url: URL) + + fileprivate var url: URL { + + let fileURL: URL + switch self { + case .default: + + guard let url = try? URL.applicationSupportDirectoryURL() else { + return URL(fileURLWithPath: "", isDirectory: true) + } + + fileURL = url + + case .custom(let url): + fileURL = url + } + + return fileURL + } + + } + +} diff --git a/DutchNews/Classes/Data Layers/Repositories/ArticleRepository.swift b/DutchNews/Classes/Data Layers/Repositories/ArticleRepository.swift new file mode 100644 index 0000000..96cb54e --- /dev/null +++ b/DutchNews/Classes/Data Layers/Repositories/ArticleRepository.swift @@ -0,0 +1,40 @@ +// +// Repository.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift + +/// `ArticleRepository` Abstract. +protocol ArticleRepository: class { + + typealias DataType = Article + + /// <#Description#> + func fetchArticles() -> Observable<[DataType]> + + /// <#Description#> + /// - Parameter keyword: <#keyword description#> + func search(keyword: String) -> Observable<[DataType]> + + /// <#Description#> + /// - Parameter articleByIdentifier: <#articleByIdentifier description#> + func find(articleByIdentifier: T) -> Observable + + /// <#Description#> + /// - Parameter articleByIdentifier: <#articleByIdentifier description#> + func find(articleByIdentifier: T) -> DataType? + + /// <#Description#> + /// - Parameter article: <#article description#> + func save(article: DataType) throws + + /// <#Description#> + /// - Parameter articles: <#articles description#> + func save(articles: [DataType]) throws + +} diff --git a/DutchNews/Classes/Data Layers/Repositories/HeadlinesArticleLocalRepository.swift b/DutchNews/Classes/Data Layers/Repositories/HeadlinesArticleLocalRepository.swift new file mode 100644 index 0000000..899f8f1 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Repositories/HeadlinesArticleLocalRepository.swift @@ -0,0 +1,79 @@ +// +// HeadlinesArticleLocalRepository.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxAlamofire +import Alamofire + +class HeadlinesArticleLocalRepository: ArticleRepository { + + typealias DataType = Article + + let storage: Storage + + init(storage: Storage) { + self.storage = storage + } + + func fetchArticles() -> Observable<[Article]> { + let storage = self.storage + return .create { obs in + + do { + try storage.fetch(type: DataType.self, + predicate: nil, + sort: nil, + completion: { (result) in + obs.on(.next(result)) + + }) + + }catch { + obs.on(.error(error)) + } + + return Disposables.create { + obs.onCompleted() + } + } + } + + func search(keyword: String) -> Observable<[Article]> { + return fetchArticles() + .map { + $0.filter({ $0.title.contains(keyword) || + $0.author?.contains(keyword) == true || + $0.content?.contains(keyword) == true + }) + } + } + + func save(article: DataType) throws { + try storage.save(object: article) + } + + func save(articles: [DataType]) throws { + for article in articles { + try self.save(article: article) + } + } + +} +// MARK: - Unneed Abstract methods implementation +extension HeadlinesArticleLocalRepository { + + func find(articleByIdentifier identifier: T) -> Observable where T: Hashable { + return .empty() + } + + func find(articleByIdentifier: T) -> DataType? where T: Hashable { + return nil + } + +} diff --git a/DutchNews/Classes/Data Layers/Repositories/HeadlinesArticleRemoteRepository.swift b/DutchNews/Classes/Data Layers/Repositories/HeadlinesArticleRemoteRepository.swift new file mode 100644 index 0000000..37b0af5 --- /dev/null +++ b/DutchNews/Classes/Data Layers/Repositories/HeadlinesArticleRemoteRepository.swift @@ -0,0 +1,79 @@ +// +// HeadlinesArticleRemoteRepository.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxAlamofire +import Alamofire + +class HeadlinesArticleRemoteRepository: ArticleRepository { + + typealias DataType = Article + + let networkService: NetworkServiceInterceptable + let validator: NetworkValidResponse? + + init(networkService: NetworkServiceInterceptable, + authentictor: RequestInterceptor, + validator: NetworkValidResponse? = nil) { + + self.networkService = networkService + self.networkService.addingRequest(interceptor: authentictor) + self.validator = validator + } + + private typealias ResponseResult = Result,Error> + + func fetchArticles() -> Observable<[Article]> { + + return networkService.executeRequest(endpoint: "top-headlines", + parameters: ["country": "nl"], + method: .get, headers: [:], + validator: validator) + .map(map(response:)) + } + + func search(keyword: String) -> Observable<[Article]> { + networkService.executeRequest(endpoint: "top-headlines", + parameters: ["q": keyword,"country": "nl"], + method: .get, headers: [:], + validator: validator) + .map(map(response:)) + } + + func get(identiferKey: K) -> [DataType] where K: Hashable { + fatalError() + } + + private func map(response: ResponseResult) throws -> [Article] { + let result = try response.get() + return result.data ?? [] + } + +} + +// MARK: - Unneed method implementation +extension HeadlinesArticleRemoteRepository { + + func find(articleByIdentifier: T) -> Observable where T: Hashable { + return .empty() + } + + func find(articleByIdentifier: T) -> DataType? where T: Hashable { + return nil + } + + func save(article: DataType) throws { + + } + + func save(articles: [DataType]) throws { + + } + +} diff --git a/DutchNews/Classes/Domains/Abstract/ArticlesUseCases.swift b/DutchNews/Classes/Domains/Abstract/ArticlesUseCases.swift new file mode 100644 index 0000000..274d965 --- /dev/null +++ b/DutchNews/Classes/Domains/Abstract/ArticlesUseCases.swift @@ -0,0 +1,27 @@ +// +// ArticlesUseCase.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift + +/// Abstract `ArticlesUseCase` +protocol ArticlesUseCase { + + typealias T = Article + + func fetchLocalArticles() -> Observable<[T]> + +} + +extension ArticlesUseCase { + + func fetchLocalArticles() -> Observable<[T]> { + return .empty() + } + +} diff --git a/DutchNews/Classes/Domains/Abstract/HeadlinesUseCases.swift b/DutchNews/Classes/Domains/Abstract/HeadlinesUseCases.swift new file mode 100644 index 0000000..2155038 --- /dev/null +++ b/DutchNews/Classes/Domains/Abstract/HeadlinesUseCases.swift @@ -0,0 +1,30 @@ +// +// HeadlinesUseCases.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift + +/// Abstract `HeadlinesUseCases` +protocol HeadlinesUseCases { + + typealias T = Article + + func fetchArticles() -> Observable<[T]> + func searchInArticle(keyword: String) -> Observable<[T]> +} + +extension HeadlinesUseCases { + + func fetchArticles() -> Observable<[T]> { + return .empty() + } + + func searchInArticle(keyword: String) -> Observable<[T]> { + return .empty() + } +} diff --git a/DutchNews/Classes/Domains/ArticlesPageUseCase.swift b/DutchNews/Classes/Domains/ArticlesPageUseCase.swift new file mode 100644 index 0000000..f83945c --- /dev/null +++ b/DutchNews/Classes/Domains/ArticlesPageUseCase.swift @@ -0,0 +1,23 @@ +// +// ArticlesPageUseCase.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/25/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift + +class ArticlesPageUseCase: ArticlesUseCase { + + let repository: ArticleRepository + + init(repository: ArticleRepository) { + self.repository = repository + } + + func fetchLocalArticles() -> Observable<[T]> { + return repository.fetchArticles() + } +} diff --git a/DutchNews/Classes/Domains/HeadlinesFetchingUseCase.swift b/DutchNews/Classes/Domains/HeadlinesFetchingUseCase.swift new file mode 100644 index 0000000..5f34e02 --- /dev/null +++ b/DutchNews/Classes/Domains/HeadlinesFetchingUseCase.swift @@ -0,0 +1,46 @@ +// +// HeadlinesFetchingUseCase.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift + +class HeadlinesFetchingUseCase: HeadlinesUseCases { + + let repository: ArticleRepository + let local: ArticleRepository? + + init(repository: ArticleRepository) { + self.repository = repository + self.local = nil + } + + init(repository: ArticleRepository, localRespository: ArticleRepository) { + self.repository = repository + self.local = localRespository + } + + func fetchArticles() -> Observable<[T]> { + return loadLocalThenFetchFromAPI() + } + + private func loadLocalThenFetchFromAPI() -> Observable<[T]> { + + let remoteSource = repository.fetchArticles() + guard let localSource = local?.fetchArticles() else { + return remoteSource + } + + let source = Observable.merge(remoteSource, localSource) + + return source + .do(afterNext: {[weak local] in + try? local?.save(articles: $0) + }) + } + +} diff --git a/DutchNews/Classes/Domains/HeadlinesSearchingUseCases.swift b/DutchNews/Classes/Domains/HeadlinesSearchingUseCases.swift new file mode 100644 index 0000000..5920297 --- /dev/null +++ b/DutchNews/Classes/Domains/HeadlinesSearchingUseCases.swift @@ -0,0 +1,24 @@ +// +// HeadlinesSearchingUseCases.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift + +class HeadlinesSearchingUseCases: HeadlinesUseCases { + + let repository: ArticleRepository + + init(repository: ArticleRepository) { + self.repository = repository + } + + func searchInArticle(keyword: String) -> Observable<[T]> { + return repository.search(keyword: keyword) + } + +} diff --git a/DutchNews/Classes/Extensions/Bundle+Extensions.swift b/DutchNews/Classes/Extensions/Bundle+Extensions.swift new file mode 100644 index 0000000..0821d51 --- /dev/null +++ b/DutchNews/Classes/Extensions/Bundle+Extensions.swift @@ -0,0 +1,20 @@ +// +// Bundle+Extensions.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +extension Bundle { + + var releaseVersionNumber: String? { + return self.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + } + var buildVersionNumber: String? { + return self.object(forInfoDictionaryKey: "CFBundleVersion") as? String + } + +} diff --git a/DutchNews/Classes/Extensions/Collection+Additionals.swift b/DutchNews/Classes/Extensions/Collection+Additionals.swift new file mode 100644 index 0000000..b66a6c9 --- /dev/null +++ b/DutchNews/Classes/Extensions/Collection+Additionals.swift @@ -0,0 +1,101 @@ +// +// Collection+Additionals.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +// MARK: - Equatable +extension Array where Element: Equatable { + + /// Remove first collection element that is equal to the given `object` + /// + /// - Parameter object: the object to remove from collection + /// - Returns: if find object return true, otherwise return false + @discardableResult + mutating func remove(object: Element) -> Bool { + if let index = firstIndex(of: object) { + self.remove(at: index) + return true + } + return false + } + + /// Remove first collection element that predicate block return true + /// + /// - Parameter predicate: the predicate block + /// - Returns: a true value if operation meet success, otherwise return false. + @discardableResult + mutating func remove(where predicate: (Array.Iterator.Element) -> Bool) -> Bool { + if let index = self.firstIndex(where: { (element) -> Bool in + return predicate(element) + }) { + self.remove(at: index) + return true + } + return false + } + + /// append the object which does not exist in the collection. + /// + /// - Parameter object: the object that insert into a collection. + /// - Returns: a true value if operation meet success, otherwise return false. + @discardableResult + mutating func append(unique object: Element) -> Bool { + guard contains(object) == false else { + return false + } + + append(object) + return true + } + +} + +extension Optional where Wrapped: Collection { + + /// indicate the current optional Collection is empty or not. + var isEmpty: Bool { + switch self { + case .some(let collection): + return collection.isEmpty + case .none : + return true + } + } + +} + +extension Collection { + + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript (safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} + +/// append right dictionary into left dictionary +/// +/// - Parameters: +/// - left: the `Dictionary` object that append. +/// - right: the `Dictionary` object insert into left collection. +func += (left: inout [K: V], right: [K: V]) { + for (key, value) in right { + left[key] = value + } +} + +/// Contact the two same dictionary into the new one. +/// +/// - Parameters: +/// - left: the left operand `Dictionary` object. +/// - right: the right operand `Dictionray' object. +/// - Returns: <#return value description#> +func + (left: [K: V], right: [K: V]) -> [K: V] { + var newValue = left + newValue += right + return newValue +} diff --git a/DutchNews/Classes/Extensions/Date+Convertor.swift b/DutchNews/Classes/Extensions/Date+Convertor.swift new file mode 100644 index 0000000..29d3e4c --- /dev/null +++ b/DutchNews/Classes/Extensions/Date+Convertor.swift @@ -0,0 +1,50 @@ +// +// Date+PersianConvertor.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +extension Date { + + static var today: Date { + return Date() + } + + static var now: Date { + return today + } + +} + +extension DateFormatter { + + static let standardFormatter: DateFormatter = { + + let calendar = Calendar.current + let locale = NSLocale.system + + let formatter = DateFormatter() + formatter.locale = locale + formatter.calendar = calendar + formatter.timeZone = NSTimeZone.system + + return formatter + }() + + static func currentZoneFormatter() -> DateFormatter { + let calendar = Calendar.current + let locale = Locale.current + + let formatter = DateFormatter() + formatter.locale = locale + formatter.calendar = calendar + + return formatter + } + +} diff --git a/DutchNews/Classes/Extensions/Date+TimeAgo.swift b/DutchNews/Classes/Extensions/Date+TimeAgo.swift new file mode 100644 index 0000000..b1e2d0e --- /dev/null +++ b/DutchNews/Classes/Extensions/Date+TimeAgo.swift @@ -0,0 +1,123 @@ +// +// Date+TimeAgo.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +private struct DateComponentUnitFormatter { + + private struct DateComponentUnitFormat { + let unit: Calendar.Component + + let singularUnit: String + let pluralUnit: String + + let futureSingular: String + let pastSingular: String + } + + private let formats: [DateComponentUnitFormat] = [ + + DateComponentUnitFormat(unit: .year, + singularUnit: NSLocalizedString("year", comment: ""), + pluralUnit: NSLocalizedString("years", comment: ""), + futureSingular: NSLocalizedString("next-year", comment: ""), + pastSingular: NSLocalizedString("last-year", comment: "")), + + DateComponentUnitFormat(unit: .month, + singularUnit: NSLocalizedString("month", comment: ""), + pluralUnit: NSLocalizedString("months", comment: ""), + futureSingular: NSLocalizedString("next-month", comment: ""), + pastSingular: NSLocalizedString("last-month", comment: "")), + + DateComponentUnitFormat(unit: .weekOfYear, + singularUnit: NSLocalizedString("week", comment: ""), + pluralUnit: NSLocalizedString("weeks", comment: ""), + futureSingular: NSLocalizedString("next-week", comment: ""), + pastSingular: NSLocalizedString("last-week", comment: "")), + + DateComponentUnitFormat(unit: .day, + singularUnit: NSLocalizedString("day", comment: ""), + pluralUnit: NSLocalizedString("days", comment: ""), + futureSingular: NSLocalizedString("Tomorrow", comment: ""), + pastSingular: NSLocalizedString("Yesterday", comment: "")), + + DateComponentUnitFormat(unit: .hour, + singularUnit: NSLocalizedString("hour", comment: ""), + pluralUnit: NSLocalizedString("hours", comment: ""), + futureSingular: NSLocalizedString("In-an-hour", comment: ""), + pastSingular: NSLocalizedString("An hour ago", comment: "")), + + DateComponentUnitFormat(unit: .minute, + singularUnit: NSLocalizedString("minute", comment: ""), + pluralUnit: NSLocalizedString("minutes", comment: ""), + futureSingular: NSLocalizedString("In a minute", comment: ""), + pastSingular: NSLocalizedString("A minute ago", comment: "")), + + DateComponentUnitFormat(unit: .second, + singularUnit: NSLocalizedString("second", comment: ""), + pluralUnit: NSLocalizedString("seconds", comment: ""), + futureSingular: NSLocalizedString("just-now", comment: ""), + pastSingular: NSLocalizedString("just-now", comment: "")) + + ] + + func string(forDateComponents dateComponents: DateComponents, useNumericDates: Bool) -> String { + for format in self.formats { + let unitValue: Int + + switch format.unit { + case .year: + unitValue = dateComponents.year ?? 0 + case .month: + unitValue = dateComponents.month ?? 0 + case .weekOfYear: + unitValue = dateComponents.weekOfYear ?? 0 + case .day: + unitValue = dateComponents.day ?? 0 + case .hour: + unitValue = dateComponents.hour ?? 0 + case .minute: + unitValue = dateComponents.minute ?? 0 + case .second: + unitValue = dateComponents.second ?? 0 + default: + assertionFailure("Date does not have requried components") + return "" + } + + switch unitValue { + case 2 ..< Int.max: + return "\(unitValue) \(format.pluralUnit) \(NSLocalizedString("ago", comment: ""))" + case 1: + return useNumericDates ? "\(unitValue) \(format.singularUnit) \(NSLocalizedString("ago", comment: ""))" : format.pastSingular + case -1: + return useNumericDates ? "In \(-unitValue) \(format.singularUnit)" : format.futureSingular + case Int.min ..< -1: + return "\(NSLocalizedString("In", comment: "")) \(-unitValue) \(format.pluralUnit)" + default: + break + } + } + + return (NSLocalizedString("just-now", comment: "")) + } +} + +extension Date { + + func timeAgoSinceNow(useNumericDates: Bool = false) -> String { + + let calendar = Calendar.current + let unitFlags: Set = [.minute, .hour, .day, .weekOfYear, .month, .year, .second] + let now = Date() + let components = calendar.dateComponents(unitFlags, from: self, to: now) + + let formatter = DateComponentUnitFormatter() + return formatter.string(forDateComponents: components, useNumericDates: useNumericDates) + } +} diff --git a/DutchNews/Classes/Extensions/DispatchQueue+Additonals.swift b/DutchNews/Classes/Extensions/DispatchQueue+Additonals.swift new file mode 100644 index 0000000..7080205 --- /dev/null +++ b/DutchNews/Classes/Extensions/DispatchQueue+Additonals.swift @@ -0,0 +1,51 @@ +// +// DispatchQueue+Additonals.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +// MARK: private functionality + +extension DispatchQueue { + + private struct QueueReference { weak var queue: DispatchQueue? } + + private static let key: DispatchSpecificKey = { + let key = DispatchSpecificKey() + setupSystemQueuesDetection(key: key) + return key + }() + + private static func _registerDetection(of queues: [DispatchQueue], key: DispatchSpecificKey) { + queues.forEach { $0.setSpecific(key: key, value: QueueReference(queue: $0)) } + } + + private static func setupSystemQueuesDetection(key: DispatchSpecificKey) { + let queues: [DispatchQueue] = [ + .main, + .global(qos: .background), + .global(qos: .default), + .global(qos: .unspecified), + .global(qos: .userInitiated), + .global(qos: .userInteractive), + .global(qos: .utility) + ] + _registerDetection(of: queues, key: key) + } +} + +// MARK: public functionality + +extension DispatchQueue { + + static func registerDetection(of queue: DispatchQueue) { + _registerDetection(of: [queue], key: key) + } + + static var currentQueueLabel: String? { current?.label } + static var current: DispatchQueue? { getSpecific(key: key)?.queue } +} diff --git a/DutchNews/Classes/Extensions/String+EmptyChecking.swift b/DutchNews/Classes/Extensions/String+EmptyChecking.swift new file mode 100644 index 0000000..3e38857 --- /dev/null +++ b/DutchNews/Classes/Extensions/String+EmptyChecking.swift @@ -0,0 +1,58 @@ +// +// String+EmptyChecking.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +// swiftlint:disable empty_first_line + +protocol OptionalString {} + +extension String: OptionalString {} +// swiftlint:enable empty_first_line + +extension Optional where Wrapped: OptionalString { + + var isEmptyOrBlank: Bool { + return String.isEmptyOrBlankString(self as? String) + } + +} + +extension String { + + func toBool() -> Bool? { + return NSString(string: self).boolValue + } + + static func isEmptyOrBlankString(_ aString: String?) -> Bool { + + if aString == nil { + return true + } + + if aString!.isEmpty { + return true + } + + if aString!.trimmingCharacters(in: CharacterSet.whitespaces).count == 0 { + return true + } + if aString!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).count == 0 { + return true + } + + return false + } + + var isEmptyOrBlank: Bool { + return String.isEmptyOrBlankString(self) + } + +} + +// swiftlint:disable all diff --git a/DutchNews/Classes/Extensions/String+HTML.swift b/DutchNews/Classes/Extensions/String+HTML.swift new file mode 100644 index 0000000..8250f0f --- /dev/null +++ b/DutchNews/Classes/Extensions/String+HTML.swift @@ -0,0 +1,25 @@ +// +// String+HTML.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +extension String { + + func convertToAttributedFromHTML() -> NSAttributedString? { + + var attributedText: NSAttributedString? + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue] + + if let data = data(using: .unicode, allowLossyConversion: true), let attrStr = try? NSAttributedString(data: data, options: options, documentAttributes: nil) { + attributedText = attrStr + } + + return attributedText + + } +} diff --git a/DutchNews/Classes/Extensions/String+Localization.swift b/DutchNews/Classes/Extensions/String+Localization.swift new file mode 100644 index 0000000..65f61bc --- /dev/null +++ b/DutchNews/Classes/Extensions/String+Localization.swift @@ -0,0 +1,24 @@ +// +// String+Localization.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +extension String { + + var localized: String { + return localized(withComment: "") + } + + func localized(withComment: String) -> String { + return NSLocalizedString(self, + tableName: nil, + bundle: Bundle.main, + value: "", comment: withComment) + } + +} diff --git a/DutchNews/Classes/Extensions/UI/UIColor+Extension.swift b/DutchNews/Classes/Extensions/UI/UIColor+Extension.swift new file mode 100644 index 0000000..223e0cf --- /dev/null +++ b/DutchNews/Classes/Extensions/UI/UIColor+Extension.swift @@ -0,0 +1,121 @@ +// +// UIColor+String.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import UIKit +import Foundation + +extension UIColor { + + convenience init?(hexString: String?) { + + guard hexString != nil else { + return nil + } + + let scanner = Scanner(string: hexString!) + scanner.scanLocation = 1 + + var rgbValue: UInt64 = 0 + + if scanner.scanHexInt64(&rgbValue) { + + self.init(rgb: rgbValue) + }else { + return nil + } + + } + + convenience init(rgb rgbValue: UInt64 = 0) { + + let red = CGFloat((rgbValue & 0xFF0000) >> 16) + let green = CGFloat((rgbValue & 0xFF00) >> 8) + let blue = CGFloat(rgbValue & 0xFF) + + self.init( + red: red / 255.0, + green: green / 255.0, + blue: blue / 255.0, + alpha: 1 + ) + + } + + static func transactionColor(for type: Int) -> UIColor { + + let color: UIColor + + switch type { + case 1, 3: + color = UIColor(rgb: 0x004f80) + + default: + color = UIColor(rgb: 0xd2a645) + } + + return color + } + + static func discountColor(for discountValue: Int) -> UIColor { + + let discountColor: UIColor + + switch discountValue { + case 1..<20: + discountColor = UIColor(rgb: 0x13b878) + + case 20..<40: + discountColor = UIColor(rgb: 0x276aae) + + case 40..<60: + discountColor = UIColor(rgb: 0x673ab7) + + case 60..<80: + discountColor = UIColor(rgb: 0xe91e63) + + case 80..<100: + discountColor = UIColor(rgb: 0xf44336) + + default: + discountColor = .clear + + } + return discountColor + } + + var hexString: String { + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + let rgb: Int = (Int)(red * 255) << 16 | (Int)(green * 255) << 8 | (Int)(blue * 255) << 0 + + return String(format: "#%06x", rgb) + + } + + var hexAlphaString: String { + + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + let rgba: Int = (Int)(red * 255) << 32 | (Int)(green * 255) << 16 | (Int)(blue * 255) << 8 | (Int)(alpha * 255) << 0 + + return String(format: "#%08x", rgba) + + } + +} diff --git a/DutchNews/Classes/Extensions/UI/UIImage+Additionals.swift b/DutchNews/Classes/Extensions/UI/UIImage+Additionals.swift new file mode 100644 index 0000000..b7c221f --- /dev/null +++ b/DutchNews/Classes/Extensions/UI/UIImage+Additionals.swift @@ -0,0 +1,383 @@ +// +// UIImage+Additionals.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +protocol OptionalImage { + +} + +extension UIImage: OptionalImage { + +} + +extension Optional where Wrapped: OptionalImage { + + var isEmpty: Bool { + return UIImage.isEmpty(self as? UIImage) + } + +} + +public enum UIImageContentMode { + case scaleToFill, scaleAspectFit, scaleAspectFill +} + +extension UIImage { + + static func isEmpty(_ aImage: UIImage?) -> Bool { + + guard let image = aImage else { + return true + } + + return image.isEmpty + } + + var isEmpty: Bool { + return self.size == .zero + } + + func resize(toSize: CGSize, contentMode: UIImageContentMode = .scaleToFill) -> UIImage? { + let horizontalRatio = size.width / self.size.width + let verticalRatio = size.height / self.size.height + var ratio: CGFloat! + + switch contentMode { + case .scaleToFill: + ratio = 1 + case .scaleAspectFill: + ratio = max(horizontalRatio, verticalRatio) + case .scaleAspectFit: + ratio = min(horizontalRatio, verticalRatio) + } + + let rect = CGRect(x: 0, y: 0, width: size.width * ratio, height: size.height * ratio) + + // Fix for a colorspace / transparency issue that affects some types of + // images. See here: http://vocaro.com/trevor/blog/2009/10/12/resize-a-uiimage-the-right-way/comment-page-2/#comment-39951 + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + let context = CGContext(data: nil, width: Int(rect.size.width), height: Int(rect.size.height), bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) + + let transform = CGAffineTransform.identity + + // Rotate and/or flip the image if required by its orientation + context?.concatenate(transform) + + // Set the quality level to use when rescaling + context!.interpolationQuality = CGInterpolationQuality(rawValue: 3)! + + //CGContextSetInterpolationQuality(context, CGInterpolationQuality(kCGInterpolationHigh.value)) + + // Draw into the context; this scales the image + context?.draw(self.cgImage!, in: rect) + + // Get the resized image from the context and a UIImage + let newImage = UIImage(cgImage: (context?.makeImage()!)!, scale: self.scale, orientation: self.imageOrientation) + return newImage + } + +} + +extension UIImage { + + /// Returns base64 string + var base64: String { + return jpegData(compressionQuality: 1.0)!.base64EncodedString() + } + + /// Returns compressed image to rate from 0 to 1 + func compressImage(rate: CGFloat) -> Data? { + return jpegData(compressionQuality: rate) + } + + /// Returns Image size in Bytes + func getSizeAsBytes() -> Int { + return jpegData(compressionQuality: 1.0)?.count ?? 0 + } + + /// Returns Image size in Kylobites + func getSizeAsKilobytes() -> Int { + let sizeAsBytes = getSizeAsBytes() + return sizeAsBytes != 0 ? sizeAsBytes / 1024 : 0 + } + + /// scales image + // swiftlint:disable:next identifier_name + class func scaleTo(image: UIImage, w: CGFloat, h: CGFloat) -> UIImage { + let newSize = CGSize(width: w, height: h) + UIGraphicsBeginImageContextWithOptions(newSize, false, 0.0) + image.draw(in: CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height)) + let newImage: UIImage = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + return newImage + } + + /// Returns resized image with width. Might return low quality + func resizeWithWidth(_ width: CGFloat) -> UIImage { + let aspectSize = CGSize(width: width, height: aspectHeightForWidth(width)) + + UIGraphicsBeginImageContextWithOptions(aspectSize, false, UIScreen.main.scale) + self.draw(in: CGRect(origin: CGPoint.zero, size: aspectSize)) + let img = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return img! + } + + /// Returns resized image with height. Might return low quality + func resizeWithHeight(_ height: CGFloat) -> UIImage { + let aspectSize = CGSize(width: aspectWidthForHeight(height), height: height) + +// UIGraphicsBeginImageContext(aspectSize) + UIGraphicsBeginImageContextWithOptions(aspectSize, false, UIScreen.main.scale) + self.draw(in: CGRect(origin: CGPoint.zero, size: aspectSize)) + let img = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return img! + } + + func aspectHeightForWidth(_ width: CGFloat) -> CGFloat { + return (width * self.size.height) / self.size.width + } + + func aspectWidthForHeight(_ height: CGFloat) -> CGFloat { + return (height * self.size.width) / self.size.height + } + + func croppedImage(_ bound: CGRect) -> UIImage? { + guard self.size.width > bound.origin.x else { + Logger.errorLog(": Your cropping X coordinate is larger than the image width", tag: "\(#function) \(#line)") + return nil + } + + guard self.size.height > bound.origin.y else { + Logger.errorLog(": Your cropping Y coordinate is larger than the image height", tag: "\(#function) \(#line)") + return nil + } + + let scaledBounds = CGRect(x: bound.minX * self.scale, y: bound.midY * self.scale, width: bound.width * self.scale, height: bound.height * self.scale) + + let imageRef = self.cgImage?.cropping(to: scaledBounds) + let croppedImage = UIImage(cgImage: imageRef!, scale: self.scale, orientation: UIImage.Orientation.up) + return croppedImage + } + + /// Use current image for pattern of color + func withColor(_ tintColor: UIColor) -> UIImage { + UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale) + + let context = UIGraphicsGetCurrentContext() + context?.translateBy(x: 0, y: self.size.height) + context?.scaleBy(x: 1.0, y: -1.0) + context?.setBlendMode(CGBlendMode.normal) + + let rect = CGRect(x: 0, y: 0, width: self.size.width, height: self.size.height) as CGRect + context?.clip(to: rect, mask: self.cgImage!) + tintColor.setFill() + context?.fill(rect) + + let newImage = UIGraphicsGetImageFromCurrentImageContext()! as UIImage + UIGraphicsEndImageContext() + + return newImage + } + + /// Returns the image associated with the URL + convenience init?(urlString: String) { + guard let url = URL(string: urlString) else { + self.init(data: Data()) + return + } + guard let data = try? Data(contentsOf: url) else { + self.init(data: Data()) + return + } + self.init(data: data) + } + + class func blankImage() -> UIImage { + UIGraphicsBeginImageContextWithOptions(CGSize(width: 1, height: 1), false, UIScreen.main.scale) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image! + } +} + +extension UIImage { + + /// SwifterSwift: Size in bytes of UIImage + var bytesSize: Int { + return jpegData(compressionQuality: 1.0)?.count ?? 0 + } + + /// SwifterSwift: Size in kilo bytes of UIImage + var kilobytesSize: Int { + return bytesSize / 1024 + } + + /// SwifterSwift: UIImage with .alwaysOriginal rendering mode. + var original: UIImage { + return withRenderingMode(.alwaysOriginal) + } + + /// SwifterSwift: UIImage with .alwaysTemplate rendering mode. + var template: UIImage { + return withRenderingMode(.alwaysTemplate) + } + +} + +// MARK: - Methods +extension UIImage { + + /// SwifterSwift: Compressed UIImage from original UIImage. + /// + /// - Parameter quality: The quality of the resulting JPEG image, expressed as a value from 0.0 to 1.0. The value 0.0 represents the maximum compression (or lowest quality) while the value 1.0 represents the least compression (or best quality), (default is 0.5). + /// - Returns: optional UIImage (if applicable). + func compressed(quality: CGFloat = 0.5) -> UIImage? { + guard let data = compressedData(quality: quality) else { + return nil + } + return UIImage(data: data) + } + + /// SwifterSwift: Compressed UIImage data from original UIImage. + /// + /// - Parameter quality: The quality of the resulting JPEG image, expressed as a value from 0.0 to 1.0. The value 0.0 represents the maximum compression (or lowest quality) while the value 1.0 represents the least compression (or best quality), (default is 0.5). + /// - Returns: optional Data (if applicable). + func compressedData(quality: CGFloat = 0.5) -> Data? { + return jpegData(compressionQuality: quality) + } + + /// SwifterSwift: UIImage Cropped to CGRect. + /// + /// - Parameter rect: CGRect to crop UIImage to. + /// - Returns: cropped UIImage + func cropped(to rect: CGRect) -> UIImage { + guard rect.size.height < size.height && rect.size.height < size.height else { + return self + } + guard let image: CGImage = cgImage?.cropping(to: rect) else { + return self + } + return UIImage(cgImage: image) + } + + /// SwifterSwift: UIImage scaled to height with respect to aspect ratio. + /// + /// - Parameters: + /// - toHeight: new height. + /// - orientation: optional UIImage orientation (default is nil). + /// - Returns: optional scaled UIImage (if applicable). + func scaled(toHeight: CGFloat, with orientation: UIImage.Orientation? = nil) -> UIImage? { + let scale = toHeight / size.height + let newWidth = size.width * scale + UIGraphicsBeginImageContextWithOptions(CGSize(width: newWidth, height: toHeight), false, UIScreen.main.scale) +// UIGraphicsBeginImageContext(CGSize(width: newWidth, height: toHeight)) + draw(in: CGRect(x: 0, y: 0, width: newWidth, height: toHeight)) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return newImage + } + + /// SwifterSwift: UIImage scaled to width with respect to aspect ratio. + /// + /// - Parameters: + /// - toWidth: new width. + /// - orientation: optional UIImage orientation (default is nil). + /// - Returns: optional scaled UIImage (if applicable). + func scaled(toWidth: CGFloat, with orientation: UIImage.Orientation? = nil) -> UIImage? { + let scale = toWidth / size.width + let newHeight = size.height * scale + + UIGraphicsBeginImageContextWithOptions(CGSize(width: toWidth, height: newHeight), false, UIScreen.main.scale) + draw(in: CGRect(x: 0, y: 0, width: toWidth, height: newHeight)) + + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return newImage + } + + /// SwifterSwift: UIImage filled with color + /// + /// - Parameter color: color to fill image with. + /// - Returns: UIImage filled with given color. + func filled(withColor color: UIColor) -> UIImage { + UIGraphicsBeginImageContextWithOptions(size, false, scale) + color.setFill() + guard let context = UIGraphicsGetCurrentContext() else { + return self + } + + context.translateBy(x: 0, y: size.height) + context.scaleBy(x: 1.0, y: -1.0) + context.setBlendMode(CGBlendMode.normal) + + let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height) + guard let mask = self.cgImage else { + return self + } + context.clip(to: rect, mask: mask) + context.fill(rect) + + let newImage = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + return newImage + } + + /// SwifterSwift: UIImage tinted with color + /// + /// - Parameters: + /// - color: color to tint image with. + /// - blendMode: how to blend the tint + /// - Returns: UIImage tinted with given color. + func tint(_ color: UIColor, blendMode: CGBlendMode) -> UIImage { + let drawRect = CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height) + UIGraphicsBeginImageContextWithOptions(size, false, scale) + let context = UIGraphicsGetCurrentContext() + context!.clip(to: drawRect, mask: cgImage!) + color.setFill() + UIRectFill(drawRect) + draw(in: drawRect, blendMode: blendMode, alpha: 1.0) + let tintedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return tintedImage! + } +} + +// MARK: - Initializers +extension UIImage { + + /// SwifterSwift: Create UIImage from color and size. + /// + /// - Parameters: + /// - color: image fill color. + /// - size: image size. + convenience init(color: UIColor, size: CGSize) { + + UIGraphicsBeginImageContextWithOptions(size, false, UIScreen.main.scale) + color.setFill() + UIRectFill(CGRect(x: 0, y: 0, width: size.width, height: size.height)) + guard let image = UIGraphicsGetImageFromCurrentImageContext() else { + self.init() + return + } + UIGraphicsEndImageContext() + guard let aCgImage = image.cgImage else { + self.init() + return + } + self.init(cgImage: aCgImage) + } + +} diff --git a/DutchNews/Classes/Extensions/UI/UIImageView+SDWebImage.swift b/DutchNews/Classes/Extensions/UI/UIImageView+SDWebImage.swift new file mode 100644 index 0000000..f1a4c7a --- /dev/null +++ b/DutchNews/Classes/Extensions/UI/UIImageView+SDWebImage.swift @@ -0,0 +1,35 @@ +// +// UIImageView+SDWebImage.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import SDWebImage + +extension UIImageView { + + static let defaultSDWebImageOptions: SDWebImageOptions = [.lowPriority,.scaleDownLargeImages,.retryFailed,.refreshCached] + static let cacheSDWebImageOptions: SDWebImageOptions = [.lowPriority,.scaleDownLargeImages,.queryMemoryData,.refreshCached] + + func setImage(url: URL?,placeHolderImage: UIImage? = #imageLiteral(resourceName: "image-placeHolder"), + contentMode: UIView.ContentMode? = .scaleAspectFill, + options: SDWebImageOptions = UIImageView.defaultSDWebImageOptions, + completed: SDExternalCompletionBlock? = nil) { + + if let contentMode = contentMode { + self.contentMode = contentMode + self.setNeedsDisplay() + } + + self.sd_setImage(with: url, placeholderImage: placeHolderImage, + options: options, completed: completed) + } + + func cancelCurrentImageLoad() { + sd_cancelCurrentImageLoad() + } + +} diff --git a/DutchNews/Classes/Extensions/UI/UILabel+Localization.swift b/DutchNews/Classes/Extensions/UI/UILabel+Localization.swift new file mode 100644 index 0000000..60f176d --- /dev/null +++ b/DutchNews/Classes/Extensions/UI/UILabel+Localization.swift @@ -0,0 +1,36 @@ +// +// UILabel+Localization.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +extension UILabel { + + // swiftlint:disable:next empty_first_line + private struct AssociatedKey { + static var localizedStringKey = "localizedStringKey" + } + + /// <#Description#> + @IBInspectable + var localizedText: String? { + get { + return objc_getAssociatedObject(self, &AssociatedKey.localizedStringKey) as? String + } + + set { + objc_setAssociatedObject(self, &AssociatedKey.localizedStringKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + guard let value = newValue else { + return + } + + self.text = value.localized + } + } + +} diff --git a/DutchNews/Classes/Extensions/UI/UINavigationBar+Additionals.swift b/DutchNews/Classes/Extensions/UI/UINavigationBar+Additionals.swift new file mode 100644 index 0000000..898a1e1 --- /dev/null +++ b/DutchNews/Classes/Extensions/UI/UINavigationBar+Additionals.swift @@ -0,0 +1,25 @@ +// +// UINavigationBar+Additionals.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +extension UINavigationItem { + + @IBInspectable + var localizedTitle: String? { + get { + return nil + } + set { + guard let newValue = newValue else { self.title = nil; return } + self.title = newValue.localized + } + } + +} diff --git a/DutchNews/Classes/Extensions/UI/UIStoryboard+Additional.swift b/DutchNews/Classes/Extensions/UI/UIStoryboard+Additional.swift new file mode 100644 index 0000000..8377618 --- /dev/null +++ b/DutchNews/Classes/Extensions/UI/UIStoryboard+Additional.swift @@ -0,0 +1,21 @@ +// +// UIStoryboard+Additional.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/25/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +extension UIStoryboard { + + func instantiateViewController(identifier: String) -> T? { + return self.instantiateViewController(withIdentifier: identifier) as? T + } + + func instantiateViewController (withIdentifer: String, type: T.Type) -> T? { + return self.instantiateViewController(withIdentifier: withIdentifer) as? T + } +} diff --git a/DutchNews/Classes/Extensions/UI/UIView+Nib.swift b/DutchNews/Classes/Extensions/UI/UIView+Nib.swift new file mode 100644 index 0000000..c0ce178 --- /dev/null +++ b/DutchNews/Classes/Extensions/UI/UIView+Nib.swift @@ -0,0 +1,67 @@ +// +// UIView+Nib.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +extension UIView { + + class func fromNib(nibNameOrNil: String? = nil) -> Self { + return fromNib(nibNameOrNil: nibNameOrNil, type: self) + } + + class func fromNib(nibNameOrNil: String? = nil, type: T.Type) -> T { + let view: T? = fromNib(nibNameOrNil: nibNameOrNil, type: T.self) + return view! + } + + class func fromNib(nibNameOrNil: String? = nil, type: T.Type) -> T? { + var view: T? + let name: String + if let nibName = nibNameOrNil { + name = nibName + } else { + // Most nibs are demangled by practice, if not, just declare string explicitly + name = String(describing: T.self) + } + + let nibViews = Bundle.main.loadNibNamed(name, owner: nil, options: nil) + + nibViews?.forEach({ (nibView) in + if let tog = nibView as? T { + view = tog + } + }) + + return view + } + +} + +extension UIView { + + class func nib(nibNameOrNil: String? = nil) -> UINib { + return nib(nibNameOrNil: nibNameOrNil, type: self) + } + + class func nib(nibNameOrNil: String? = nil, type: T.Type) -> UINib { + + let name: String + + if let nibName = nibNameOrNil { + name = nibName + } else { + // Most nibs are demangled by practice, if not, just declare string explicitly + name = String(describing: type) + } + + let bundle = Bundle(for: type) + + return UINib(nibName: name, bundle: bundle) + } +} diff --git a/DutchNews/Classes/Extensions/UI/UIViewController+AlertableView.swift b/DutchNews/Classes/Extensions/UI/UIViewController+AlertableView.swift new file mode 100644 index 0000000..24b7f2d --- /dev/null +++ b/DutchNews/Classes/Extensions/UI/UIViewController+AlertableView.swift @@ -0,0 +1,25 @@ +// +// UIViewController+AlertableView.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +// MARK: - UIViewController extension for AlertableView Abstract. +extension AlertableView where Self: UIViewController { + + /// <#Description#> + /// + /// - Parameters: + /// - message: <#message description#> + /// - actionTitle: <#actionTitle description#> + /// - actionHandler: <#actionHandler description#> + func presentAlertView(withMessage message: String, actionTitle: String? = nil, actionHandler: @escaping () -> Void = {}) { + presentAlert(message: message, actionTitle: actionTitle, actionHandler: actionHandler) + } + +} diff --git a/DutchNews/Classes/Extensions/UI/UIViewController+StoryboardName.swift b/DutchNews/Classes/Extensions/UI/UIViewController+StoryboardName.swift new file mode 100644 index 0000000..4cf4b8f --- /dev/null +++ b/DutchNews/Classes/Extensions/UI/UIViewController+StoryboardName.swift @@ -0,0 +1,18 @@ +// +// UIViewController+StoryboardName.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +extension UIViewController { + + class var className: String { + return String(describing: self) + } + +} diff --git a/DutchNews/Classes/Extensions/URL+ApplicationPath.swift b/DutchNews/Classes/Extensions/URL+ApplicationPath.swift new file mode 100644 index 0000000..0af95ba --- /dev/null +++ b/DutchNews/Classes/Extensions/URL+ApplicationPath.swift @@ -0,0 +1,35 @@ +// +// URL+ApplicationPath.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +extension URL { + + static func applicationSupportDirectoryURL() throws -> URL { + return try getSearchingPath(for: .applicationSupportDirectory) + } + + static func documentDirectoryURL() throws -> URL { + return try getSearchingPath(for: .documentDirectory) + } + + static func cacheDirectoryURL() throws -> URL { + return try getSearchingPath(for: .cachesDirectory) + } + + static func getSearchingPath(for searchingPath: FileManager.SearchPathDirectory = .applicationSupportDirectory) throws -> URL { + guard let path = FileManager.default.urls(for: searchingPath, in: .userDomainMask).last else { + throw POSIXError(.ENOENT) + } + + try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true, attributes: nil) + + return path + } + +} diff --git a/DutchNews/Classes/Extensions/WebKit/Rx+WebKit.swift b/DutchNews/Classes/Extensions/WebKit/Rx+WebKit.swift new file mode 100644 index 0000000..e8c4929 --- /dev/null +++ b/DutchNews/Classes/Extensions/WebKit/Rx+WebKit.swift @@ -0,0 +1,78 @@ +// +// Rx+WebKit.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/23/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import WebKit +import RxSwift +import RxCocoa + +extension Reactive where Base: WKWebView { + /** + Reactive wrapper for `title` property + */ + public var title: Observable { + return self.observeWeakly(String.self, "title") + } + + /** + Reactive wrapper for `loading` property. + */ + public var loading: Observable { + return self.observeWeakly(Bool.self, "loading") + .map { $0 ?? false } + } + + /** + Reactive wrapper for `estimatedProgress` property. + */ + public var estimatedProgress: Observable { + return self.observeWeakly(Double.self, "estimatedProgress") + .map { $0 ?? 0.0 } + } + + /** + Reactive wrapper for `url` property. + */ + public var url: Observable { + return self.observeWeakly(URL.self, "URL") + } + + /** + Reactive wrapper for `canGoBack` property. + */ + public var canGoBack: Observable { + return self.observeWeakly(Bool.self, "canGoBack") + .map { $0 ?? false } + } + + /** + Reactive wrapper for `canGoForward` property. + */ + public var canGoForward: Observable { + return self.observeWeakly(Bool.self, "canGoForward") + .map { $0 ?? false } + } + + /// Reactive wrapper for `evaluateJavaScript(_:completionHandler:)` method. + /// + /// - Parameter javaScriptString: The JavaScript string to evaluate. + /// - Returns: Observable sequence of result of the script evaluation. + public func evaluateJavaScript(_ javaScriptString: String) -> Observable { + return Observable.create { [weak base] observer in + base?.evaluateJavaScript(javaScriptString) { value, error in + if let error = error { + observer.onError(error) + } else { + observer.onNext(value) + observer.onCompleted() + } + } + return Disposables.create() + } + } +} diff --git a/DutchNews/Classes/Extensions/WebKit/RxWKNavigationDelegateProxy.swift b/DutchNews/Classes/Extensions/WebKit/RxWKNavigationDelegateProxy.swift new file mode 100644 index 0000000..df87aa9 --- /dev/null +++ b/DutchNews/Classes/Extensions/WebKit/RxWKNavigationDelegateProxy.swift @@ -0,0 +1,44 @@ +// +// RxWKNavigationDelegateProxy.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/23/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import WebKit +#if !RX_NO_MODULE + import RxSwift + import RxCocoa +#endif + +public typealias RxWKNavigationDelegate = DelegateProxy + +open class RxWKNavigationDelegateProxy: RxWKNavigationDelegate, DelegateProxyType, WKNavigationDelegate { + + /// Type of parent object + public weak private(set) var webView: WKWebView? + + /// Init with ParentObject + public init(parentObject: ParentObject) { + webView = parentObject + super.init(parentObject: parentObject, delegateProxy: RxWKNavigationDelegateProxy.self) + } + + /// Register self to known implementations + public static func registerKnownImplementations() { + self.register { parent -> RxWKNavigationDelegateProxy in + RxWKNavigationDelegateProxy(parentObject: parent) + } + } + + /// Gets the current `WKNavigationDelegate` on `WKWebView` + open class func currentDelegate(for object: ParentObject) -> WKNavigationDelegate? { + return object.navigationDelegate + } + + /// Set the navigationDelegate for `WKWebView` + open class func setCurrentDelegate(_ delegate: WKNavigationDelegate?, to object: ParentObject) { + object.navigationDelegate = delegate + } +} diff --git a/DutchNews/Classes/Extensions/WebKit/RxWKUIDelegateEvents+Rx.swift b/DutchNews/Classes/Extensions/WebKit/RxWKUIDelegateEvents+Rx.swift new file mode 100644 index 0000000..202e179 --- /dev/null +++ b/DutchNews/Classes/Extensions/WebKit/RxWKUIDelegateEvents+Rx.swift @@ -0,0 +1,99 @@ +// +// RxWKUIDelegateEvents+Rx.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/23/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +#if !RX_NO_MODULE + import RxSwift + import RxCocoa +#endif + +import WebKit + +private func castOrThrow(_ resultType: T.Type, _ object: Any) throws -> T { + guard let returnValue = object as? T else { + throw RxCocoaError.castingError(object: object, targetType: resultType) + } + + return returnValue +} +//swiftlint:disable all +extension Reactive where Base: WKWebView { + public typealias JSAlertEvent = (webView: WKWebView, message: String, frame: WKFrameInfo, handler: () -> Void) + public typealias JSConfirmEvent = (webView: WKWebView, message: String, frame: WKFrameInfo, handler: (Bool) -> Void) + public typealias CommitPreviewEvent = (webView: WKWebView, controller: UIViewController) + + /// Reactive wrapper for `navigationDelegate`. + public var uiDelegate: DelegateProxy { + return RxWKUIDelegateProxy.proxy(for: base) + } + + /// Reactive wrapper for `func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Swift.Void)` + public var javaScriptAlertPanel: ControlEvent { + typealias __CompletionHandler = @convention(block) () -> Void + let source: Observable = uiDelegate + .methodInvoked(.jsAlert).map { args in + let view = try castOrThrow(WKWebView.self, args[0]) + let message = try castOrThrow(String.self, args[1]) + let frame = try castOrThrow(WKFrameInfo.self, args[2]) + var closureObject: AnyObject? + var mutableArgs = args + mutableArgs.withUnsafeMutableBufferPointer { ptr in + closureObject = ptr[3] as AnyObject + } + let __completionBlockPtr = UnsafeRawPointer(Unmanaged.passUnretained(closureObject as AnyObject).toOpaque()) + let handler = unsafeBitCast(__completionBlockPtr, to: __CompletionHandler.self) + return (view, message, frame, handler) + } + + return ControlEvent(events: source) + } + + /// Reactive wrapper for `func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Swift.Void)` + public var javaScriptConfirmPanel: ControlEvent { + typealias __ConfirmHandler = @convention(block) (Bool) -> Void + let source: Observable = uiDelegate + .methodInvoked(.jsConfirm).map { args in + let view = try castOrThrow(WKWebView.self, args[0]) + let message = try castOrThrow(String.self, args[1]) + let frame = try castOrThrow(WKFrameInfo.self, args[2]) + var closureObject: AnyObject? + var mutableArgs = args + mutableArgs.withUnsafeMutableBufferPointer { ptr in + closureObject = ptr[3] as AnyObject + } + let __confirmBlockPtr = UnsafeRawPointer(Unmanaged.passUnretained(closureObject as AnyObject).toOpaque()) + let handler = unsafeBitCast(__confirmBlockPtr, to: __ConfirmHandler.self) + return (view, message, frame, handler) + } + + return ControlEvent(events: source) + } + + /// Reactive wrappper for `func webView(_ webView: WKWebView, commitPreviewingViewController previewingViewController: UIViewController)` + @available(iOS 10.0, *) + public var commitPreviewing: ControlEvent { + let source: Observable = uiDelegate + .methodInvoked(.commitPreviewing) + .map { args in + let view = try castOrThrow(WKWebView.self, args[0]) + let controller = try castOrThrow(UIViewController.self, args[1]) + return (view, controller) + } + + return ControlEvent(events: source) + } +} + +//swiftlint:enable all + +fileprivate extension Selector { + + static let jsAlert = #selector(WKUIDelegate.webView(_:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:)) + static let jsConfirm = #selector(WKUIDelegate.webView(_:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:)) + @available(iOS 10.0, *) + static let commitPreviewing = #selector(WKUIDelegate.webView(_:commitPreviewingViewController:)) +} diff --git a/DutchNews/Classes/Extensions/WebKit/RxWKUIDelegateProxy.swift b/DutchNews/Classes/Extensions/WebKit/RxWKUIDelegateProxy.swift new file mode 100644 index 0000000..28e71ea --- /dev/null +++ b/DutchNews/Classes/Extensions/WebKit/RxWKUIDelegateProxy.swift @@ -0,0 +1,45 @@ +// +// RxWKUIDelegateProxy.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/23/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import WebKit +#if !RX_NO_MODULE + import RxSwift + import RxCocoa +#endif + +public typealias RxWKUIDelegate = DelegateProxy + +open class RxWKUIDelegateProxy: RxWKUIDelegate, DelegateProxyType, WKUIDelegate { + + /// Type of parent object + /// must be WKWebView! + public weak private(set) var webView: WKWebView? + + /// Init with ParentObject + public init(parentObject: ParentObject) { + webView = parentObject + super.init(parentObject: parentObject, delegateProxy: RxWKUIDelegateProxy.self) + } + + /// Register self to known implementations + public static func registerKnownImplementations() { + self.register { parent -> RxWKUIDelegateProxy in + RxWKUIDelegateProxy(parentObject: parent) + } + } + + /// Gets the current `WKUIDelegate` on `WKWebView` + open class func currentDelegate(for object: ParentObject) -> WKUIDelegate? { + return object.uiDelegate + } + + /// Set the uiDelegate for `WKWebView` + open class func setCurrentDelegate(_ delegate: WKUIDelegate?, to object: ParentObject) { + object.uiDelegate = delegate + } +} diff --git a/DutchNews/Classes/Extensions/WebKit/RxWKUserContentController.swift b/DutchNews/Classes/Extensions/WebKit/RxWKUserContentController.swift new file mode 100644 index 0000000..cc4a3d8 --- /dev/null +++ b/DutchNews/Classes/Extensions/WebKit/RxWKUserContentController.swift @@ -0,0 +1,47 @@ +// +// RxWKUserContentController.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/23/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import WebKit +#if !RX_NO_MODULE + import RxSwift + import RxCocoa +#endif + +extension WKUserContentController { + fileprivate class MessageHandler: NSObject, WKScriptMessageHandler { + typealias MessageReceiveHandler = (WKScriptMessage) -> Void + private var messageReceiveHandler: MessageReceiveHandler? + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + self.messageReceiveHandler?(message) + } + + func onReceive(_ handler:@escaping MessageReceiveHandler) { + self.messageReceiveHandler = handler + } + } +} + +public extension Reactive where Base: WKUserContentController { + /// Observable sequence of script message. + /// + /// - Parameter name: The name of the message handler + /// - Returns: Observable sequence of script message. + func scriptMessage(forName name: String) -> ControlEvent { + return ControlEvent(events: Observable.create { [weak base] observer in + let handler = WKUserContentController.MessageHandler() + base?.add(handler, name: name) + handler.onReceive { + observer.onNext($0) + } + return Disposables.create { + base?.removeScriptMessageHandler(forName: name) + } + }) + } +} diff --git a/DutchNews/Classes/Extensions/WebKit/WKNavigationDelegateEvents+Rx.swift b/DutchNews/Classes/Extensions/WebKit/WKNavigationDelegateEvents+Rx.swift new file mode 100644 index 0000000..f598cea --- /dev/null +++ b/DutchNews/Classes/Extensions/WebKit/WKNavigationDelegateEvents+Rx.swift @@ -0,0 +1,253 @@ +// +// WKNavigationDelegateEvents+Rx.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/23/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +#if !RX_NO_MODULE + import RxSwift + import RxCocoa +#endif + +import WebKit + +private func castOrThrow(_ resultType: T.Type, _ object: Any) throws -> T { + guard let returnValue = object as? T else { + throw RxCocoaError.castingError(object: object, targetType: resultType) + } + + return returnValue +} +//swiftlint:disable all +extension Reactive where Base: WKWebView { + /// WKNavigationEvent emits a tuple that contains both + /// WKWebView + WKNavigation + public typealias WKNavigationEvent = (webView: WKWebView, navigation: WKNavigation) + + /// WKNavigationFailedEvent emits a tuple that contains both + /// WKWebView + WKNavigation + Swift.Error + public typealias WKNavigationFailEvent = (webView: WKWebView, navigation: WKNavigation, error: Error) + + /// ChallengeHandler this is exposed to the user on subscription + public typealias ChallengeHandler = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + /// WKNavigationChallengeEvent emits a tuple event of WKWebView + challenge + ChallengeHandler + public typealias WKNavigationChallengeEvent = (webView: WKWebView, challenge: URLAuthenticationChallenge, handler: ChallengeHandler) + + /// DecisionHandler this is the block exposed to the user on subscription + public typealias DecisionHandler = (WKNavigationResponsePolicy) -> Void + /// WKNavigationResponsePolicyEvent emits a tuple event of WKWebView + WKNavigationResponse + DecisionHandler + public typealias WKNavigationResponsePolicyEvent = ( webView: WKWebView, reponse: WKNavigationResponse, handler: DecisionHandler) + /// ActionHandler this is the block exposed to the user on subscription + public typealias ActionHandler = (WKNavigationActionPolicy) -> Void + /// WKNavigationActionPolicyEvent emits a tuple event of WKWebView + WKNavigationAction + ActionHandler + public typealias WKNavigationActionPolicyEvent = ( webView: WKWebView, action: WKNavigationAction, handler: ActionHandler) + + private func navigationEventWith(_ arg: [Any]) throws -> WKNavigationEvent { + let view = try castOrThrow(WKWebView.self, arg[0]) + let nav = try castOrThrow(WKNavigation.self, arg[1]) + return (view, nav) + } + + private func navigationFailEventWith(_ arg: [Any]) throws -> WKNavigationFailEvent { + let view = try castOrThrow(WKWebView.self, arg[0]) + let nav = try castOrThrow(WKNavigation.self, arg[1]) + let error = try castOrThrow(Swift.Error.self, arg[2]) + return (view, nav, error) + } + + /// Reactive wrapper for `navigationDelegate`. + public var delegate: DelegateProxy { + return RxWKNavigationDelegateProxy.proxy(for: base) + } + + /// Reactive wrapper for delegate method `webView(_ webView: WKWebView, didCommit navigation: WKNavigation!)`. + public var didCommitNavigation: ControlEvent { + let source: Observable = delegate + .methodInvoked(.didCommitNavigation) + .map(navigationEventWith) + return ControlEvent(events: source) + } + + /// Reactive wrapper for delegate method `webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!)`. + public var didStartProvisionalNavigation: ControlEvent { + let source: Observable = delegate + .methodInvoked(.didStartProvisionalNavigation) + .map(navigationEventWith) + return ControlEvent(events: source) + } + + /// Reactive wrapper for delegate method `webView(_ webView: WKWebView, didFinish navigation: WKNavigation!)` + public var didFinishNavigation: ControlEvent { + let source: Observable = delegate + .methodInvoked(.didFinishNavigation) + .map(navigationEventWith) + return ControlEvent(events: source) + } + + /// Reactive wrapper for delegate method `webViewWebContentProcessDidTerminate(_ webView: WKWebView)`. + @available(iOS 9.0, *) + public var didTerminate: ControlEvent { + let source: Observable = delegate + .methodInvoked(.didTerminate) + .map { try castOrThrow(WKWebView.self, $0[0]) } + return ControlEvent(events: source) + } + + /// Reactive wrapper for delegate method `webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!)`. + public var didReceiveServerRedirectForProvisionalNavigation: ControlEvent { + let source: Observable = delegate + .methodInvoked(.didReceiveServerRedirectForProvisionalNavigation) + .map(navigationEventWith) + return ControlEvent(events: source) + } + + /// Reactive wrapper for delegate method `webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error)`. + public var didFailNavigation: ControlEvent { + let source: Observable = delegate + .methodInvoked(.didFailNavigation) + .map(navigationFailEventWith) + return ControlEvent(events: source) + } + + /// Reactive wrapper for delegate method `webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error)`. + public var didFailProvisionalNavigation: ControlEvent { + let source: Observable = delegate + .methodInvoked(.didFailProvisionalNavigation) + .map(navigationFailEventWith) + return ControlEvent(events: source) + } + + /// Reactive wrapper for delegate method `webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)` + public var didReceiveChallenge: ControlEvent { + /// __ChallengeHandler is same as ChallengeHandler + /// They are interchangeable, __ChallengeHandler is for internal use. + /// ChallengeHandler is exposed to the user on subscription. + /// @convention attribute makes the swift closure compatible with Objc blocks + typealias __ChallengeHandler = @convention(block) (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + /*! @abstract Invoked when the web view needs to respond to an authentication challenge. + @param webView The web view that received the authentication challenge. + @param challenge The authentication challenge. + @param completionHandler The completion handler you must invoke to respond to the challenge. The + disposition argument is one of the constants of the enumerated type + NSURLSessionAuthChallengeDisposition. When disposition is NSURLSessionAuthChallengeUseCredential, + the credential argument is the credential to use, or nil to indicate continuing without a + credential. + @discussion If you do not implement this method, the web view will respond to the authentication challenge with the NSURLSessionAuthChallengeRejectProtectionSpace disposition. + */ + let source: Observable = delegate + .sentMessage(.didReceiveChallenge) + .map { arg in + /// Extracting the WKWebView from the array at index zero + /// which is the first argument of the function signature + let view = try castOrThrow(WKWebView.self, arg[0]) + /// Extracting the URLAuthenticationChallenge from the array at index one + /// which is the second argument of the function signature + let challenge = try castOrThrow(URLAuthenticationChallenge.self, arg[1]) + /// Now you `Can't` transform closure easily because they are excuted + /// in the stack if try it you will get the famous error + /// `Could not cast value of type '__NSStackBlock__' (0x12327d1a8) to` + /// this is because closures are transformed into a system type which is `__NSStackBlock__` + /// the above mentioned type is not exposed to `developer`. So everytime + /// you execute a closure the compiler transforms it into this Object. + /// So you go through the following steps to get a human readable type + /// of the closure signature: + /// 1. closureObject is type of AnyObject to that holds the raw value from + /// the array. + var closureObject: AnyObject? + /// 2. make the array mutable in order to access the `withUnsafeMutableBufferPointer` + /// fuctionalities + var mutableArg = arg + /// 3. Grab the closure at index 3 of the array, but we have to use the C-style + /// approach to access the raw memory underpinning the array and store it in closureObject + /// Now the object stored in the `closureObject` is `Unmanaged` and `some unspecified type` + /// the intelligent swift compiler doesn't know what sort of type it contains. It is Raw. + mutableArg.withUnsafeMutableBufferPointer { ptr in + closureObject = ptr[2] as AnyObject + } + /// 4. instantiate an opaque pointer to referenc the value of the `unspecified type` + let __challengeBlockPtr = UnsafeRawPointer(Unmanaged.passUnretained(closureObject as AnyObject).toOpaque()) + /// 5. Here the magic happen we forcefully tell the compiler that anything + /// found at this memory address that is refrenced should be a type of + /// `__ChallengeHandler`! + let handler = unsafeBitCast(__challengeBlockPtr, to: __ChallengeHandler.self) + return (view, challenge, handler) + } + + return ControlEvent(events: source) + + /** + Reference: + + This is a holy grail part for more information please read the following articles. + 1: http://codejaxy.com/q/332345/ios-objective-c-memory-management-automatic-ref-counting-objective-c-blocks-understand-one-edge-case-of-block-memory-management-in-objc + 2: http://www.galloway.me.uk/2012/10/a-look-inside-blocks-episode-2/ + 3: https://maniacdev.com/2013/11/tutorial-an-in-depth-guide-to-objective-c-block-debugging + 4: get know how [__NSStackBlock__ + UnsafeRawPointer + unsafeBitCast] works under the hood + 5: https://en.wikipedia.org/wiki/Opaque_pointer + 6: https://stackoverflow.com/questions/43662363/cast-objective-c-block-nsstackblock-into-swift-3 + */ + } + + /// Reactive wrapper for `func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Swift.Void)` + public var decidePolicyNavigationResponse: ControlEvent { + typealias __DecisionHandler = @convention(block) (WKNavigationResponsePolicy) -> Void + let source: Observable = delegate + .methodInvoked(.decidePolicyNavigationResponse).map { args in + let view = try castOrThrow(WKWebView.self, args[0]) + let response = try castOrThrow(WKNavigationResponse.self, args[1]) + var closureObject: AnyObject? + var mutableArgs = args + mutableArgs.withUnsafeMutableBufferPointer { ptr in + closureObject = ptr[2] as AnyObject + } + let __decisionBlockPtr = UnsafeRawPointer(Unmanaged.passUnretained(closureObject as AnyObject).toOpaque()) + let handler = unsafeBitCast(__decisionBlockPtr, to: __DecisionHandler.self) + return (view, response, handler) + } + + return ControlEvent(events: source) + } + + /// Reactive wrapper for `func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Swift.Void)` + public var decidePolicyNavigationAction: ControlEvent { + typealias __ActionHandler = @convention(block) (WKNavigationActionPolicy) -> Void + let source: Observable = delegate + .methodInvoked(.decidePolicyNavigationAction).map { args in + let view = try castOrThrow(WKWebView.self, args[0]) + let action = try castOrThrow(WKNavigationAction.self, args[1]) + var closureObject: AnyObject? + var mutableArgs = args + mutableArgs.withUnsafeMutableBufferPointer { ptr in + closureObject = ptr[2] as AnyObject + } + let __actionBlockPtr = UnsafeRawPointer(Unmanaged.passUnretained(closureObject as AnyObject).toOpaque()) + let handler = unsafeBitCast(__actionBlockPtr, to: __ActionHandler.self) + return (view, action, handler) + } + + return ControlEvent(events: source) + } +} + +extension Selector { + static let didCommitNavigation = #selector(WKNavigationDelegate.webView(_:didCommit:)) + static let didStartProvisionalNavigation = #selector(WKNavigationDelegate.webView(_:didStartProvisionalNavigation:)) + static let didFinishNavigation = #selector(WKNavigationDelegate.webView(_:didFinish:)) + static let didReceiveServerRedirectForProvisionalNavigation = #selector(WKNavigationDelegate.webView(_:didReceiveServerRedirectForProvisionalNavigation:)) + static let didFailNavigation = #selector(WKNavigationDelegate.webView(_:didFail:withError:)) + static let didFailProvisionalNavigation = #selector(WKNavigationDelegate.webView(_:didFailProvisionalNavigation:withError:)) + static let didReceiveChallenge = #selector(WKNavigationDelegate.webView(_:didReceive:completionHandler:)) + @available(iOS 9.0, *) + static let didTerminate = #selector(WKNavigationDelegate.webViewWebContentProcessDidTerminate(_:)) + /// Xcode give error when selectors results into having same signature + /// because of swift style you get for example: + /// Ambiguous use of 'webView(_:decidePolicyFor:decisionHandler:)' + /// please see this link for further understanding + /// https://bugs.swift.org/browse/SR-3062 + static let decidePolicyNavigationResponse = #selector(WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:) as ((WKNavigationDelegate) -> (WKWebView, WKNavigationResponse, @escaping(WKNavigationResponsePolicy) -> Void) -> Void)?) + static let decidePolicyNavigationAction = #selector(WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:) as ((WKNavigationDelegate) -> (WKWebView, WKNavigationAction, @escaping(WKNavigationActionPolicy) -> Void) -> Void)?) +} + +//swiftlint:enable all diff --git a/DutchNews/Classes/Models/Article.swift b/DutchNews/Classes/Models/Article.swift new file mode 100644 index 0000000..541ef0e --- /dev/null +++ b/DutchNews/Classes/Models/Article.swift @@ -0,0 +1,95 @@ +// +// Article.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +struct Article: Storable, Codable { + + let title: String + let author: String? + let description: String? + + let source: ArticleSource + + let url: URL + + var urlToImage: URL? { + guard let urlStr = imageUrl, let url = URL(string: urlStr) else { + return nil + } + return url + } + + private let imageUrl: String? + + let publishedAt: Date + + let content: String? + + var type: ArticleType = .news + + enum CodingKeys: String, CodingKey { + case source, author, title + case description + case url + case imageUrl = "urlToImage" + case publishedAt, content + } + + init(title: String, author: String? = nil, description: String? = nil, + source: ArticleSource, + url: URL, imageUrl: URL? = nil , publishedAt: Date = .now, + content: String? = nil, type: ArticleType = .news) { + + self.title = title + self.author = author + self.description = description + self.url = url + self.imageUrl = imageUrl?.absoluteString + self.publishedAt = publishedAt + self.source = source + self.content = content + self.type = type + + } + + func primaryKeyValue() -> String { + return url.absoluteString + "\(publishedAt.timeIntervalSince1970)" + } +} + +enum ArticleType: Int, Codable { + case news + case mock +} + +extension Article: Hashable { + + func hash(into hasher: inout Hasher) { + hasher.combine(title) + hasher.combine(source) + hasher.combine(url) + hasher.combine(type) + hasher.combine(publishedAt) + + } +} + +extension Article { + + static func htmlArticle() -> Article { + + return .init(title: "", author: "", description: "", source: ArticleSource(id: "", name: ""), + url: URL(string: "https://domain.com")!, + imageUrl: nil, publishedAt: Date(), + content: """ +
\n \n \n \n
+ """,type: .mock) + } + +} diff --git a/DutchNews/Classes/Models/ArticleSource.swift b/DutchNews/Classes/Models/ArticleSource.swift new file mode 100644 index 0000000..fc294f9 --- /dev/null +++ b/DutchNews/Classes/Models/ArticleSource.swift @@ -0,0 +1,21 @@ +// +// ArticleSource.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +struct ArticleSource: Codable, Hashable { + + let id: String? + let name: String + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(name) + } + +} diff --git a/DutchNews/Classes/Utilites/HeadlineLayoutConfiguration/ArticleHeadlineLayoutConfiguration.swift b/DutchNews/Classes/Utilites/HeadlineLayoutConfiguration/ArticleHeadlineLayoutConfiguration.swift new file mode 100644 index 0000000..78d0d0b --- /dev/null +++ b/DutchNews/Classes/Utilites/HeadlineLayoutConfiguration/ArticleHeadlineLayoutConfiguration.swift @@ -0,0 +1,48 @@ +// +// ArticleHeadlineLayoutConfiguration.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import MagazineLayout +import UIKit + +struct ArticleHeadlineLayoutConfiguration: HeadlineLayoutConfiguration { + + func itemSizeMode(forItemAt indexPath: IndexPath) -> MagazineLayoutItemSizeMode { + switch (indexPath.section, indexPath.row) { + case (_,0): + return sizeModeCreate(widthMode: .fullWidth(respectsHorizontalInsets: true), heightMode: .dynamic) + case (_,3): + return sizeModeCreate(widthMode: .fullWidth(respectsHorizontalInsets: true), heightMode: .static(height: 80)) + case (_,1), + (_,2): + return sizeModeCreate(widthMode: .halfWidth, heightMode: .dynamicAndStretchToTallestItemInRow) + default: + return sizeModeCreate() + } + } + + func verticalSpacing(forElementsInSectionAtIndex section: Int) -> CGFloat { + 5.0 + } + + func horizontalSpacing(forElementsInSectionAtIndex section: Int) -> CGFloat { + 5.0 + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Private Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + private func sizeModeCreate(widthMode: MagazineLayoutItemWidthMode = .fullWidth(respectsHorizontalInsets: true), + heightMode: MagazineLayoutItemHeightMode = .dynamic) -> MagazineLayoutItemSizeMode { + return MagazineLayoutItemSizeMode(widthMode: widthMode,heightMode: heightMode) + } + +} diff --git a/DutchNews/Classes/Utilites/HeadlineLayoutConfiguration/HeadlineLayoutConfiguration.swift b/DutchNews/Classes/Utilites/HeadlineLayoutConfiguration/HeadlineLayoutConfiguration.swift new file mode 100644 index 0000000..993d745 --- /dev/null +++ b/DutchNews/Classes/Utilites/HeadlineLayoutConfiguration/HeadlineLayoutConfiguration.swift @@ -0,0 +1,67 @@ +// +// HeadlineLayoutConfiguration.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit +import MagazineLayout + +/// `HeadlineLayoutConfiguration` Abstract +protocol HeadlineLayoutConfiguration { + + var defaultHeight: CGFloat { get } + + func itemSizeMode(forItemAt indexPath: IndexPath) -> MagazineLayoutItemSizeMode + func visibleMode(forFooterAt section: Int) -> MagazineLayoutFooterVisibilityMode + func visibleMode(forHeaderAt section: Int) -> MagazineLayoutHeaderVisibilityMode + func visibleMode(forBackground section: Int) -> MagazineLayoutBackgroundVisibilityMode + + func insets(forSectionAtIndex section: Int) -> UIEdgeInsets + func insets(forItemsInSectionAtIndex section: Int) -> UIEdgeInsets + + func verticalSpacing(forElementsInSectionAtIndex section: Int) -> CGFloat + func horizontalSpacing(forElementsInSectionAtIndex section: Int) -> CGFloat + +} + +extension HeadlineLayoutConfiguration { + + var defaultHeight: CGFloat { 44 } + + func itemSizeMode(forItemAt indexPath: IndexPath) -> MagazineLayoutItemSizeMode { + return MagazineLayoutItemSizeMode(widthMode: .fullWidth(respectsHorizontalInsets: true), + heightMode: MagazineLayoutItemHeightMode.static(height: defaultHeight)) + } + + func visibleMode(forFooterAt section: Int) -> MagazineLayoutFooterVisibilityMode { + .hidden + } + + func visibleMode(forHeaderAt section: Int) -> MagazineLayoutHeaderVisibilityMode { + .hidden + } + + func visibleMode(forBackground section: Int) -> MagazineLayoutBackgroundVisibilityMode { + .hidden + } + + func insets(forSectionAtIndex section: Int) -> UIEdgeInsets { + return .init(top: 8, left: 8, bottom: 8, right: 8) + } + + func insets(forItemsInSectionAtIndex section: Int) -> UIEdgeInsets { + return .zero + } + + func verticalSpacing(forElementsInSectionAtIndex section: Int) -> CGFloat { + return 0 + } + + func horizontalSpacing(forElementsInSectionAtIndex section: Int) -> CGFloat { + return 0 + } +} diff --git a/DutchNews/Classes/Utilites/Logger.swift b/DutchNews/Classes/Utilites/Logger.swift new file mode 100644 index 0000000..7ce03f2 --- /dev/null +++ b/DutchNews/Classes/Utilites/Logger.swift @@ -0,0 +1,107 @@ +// +// Logger.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import CocoaLumberjack + +/// App Logger class, in order to log async in xcode project. +struct Logger { + + /// Log the message passed into function with debug level. + /// + /// - Parameters: + /// - message: The message will be log + /// - file: The file name which the method called by. + /// - function: The function name which log method called in. + /// - line: The line number of file + /// - tag: The external tags which provide more information. + static func debugLog(_ message: @autoclosure () -> String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil) { + DDLogDebug(message(), file: file, function: function, line: line, tag: tag) + } + + /// Log the message passed into function with info level. + /// + /// - Parameters: + /// - message: The message will be log + /// - file: The file name which the method called by. + /// - function: The function name which log method called in. + /// - line: The line number of file + /// - tag: The external tags which provide more information. + static func infoLog(_ message: @autoclosure () -> String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil) { + DDLogInfo(message(), file: file, function: function, line: line, tag: tag) + } + + /// Log the message passed into function with warn level. + /// + /// - Parameters: + /// - message: The message will be log + /// - file: The file name which the method called by. + /// - function: The function name which log method called in. + /// - line: The line number of file + /// - tag: The external tags which provide more information. + static func warnLog(_ message: @autoclosure () -> String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil) { + DDLogWarn(message(), file: file, function: function, line: line, tag: tag) + } + + /// Log the message passed into function with verbose level. + /// + /// - Parameters: + /// - message: The message will be log + /// - file: The file name which the method called by. + /// - function: The function name which log method called in. + /// - line: The line number of file + /// - tag: The external tags which provide more information. + static func verboseLog(_ message: @autoclosure () -> String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil) { + DDLogVerbose(message(), file: file, function: function, line: line, tag: tag) + } + + /// Log the message passed into function with error level. + /// - Note: this method log and effect immediately after called. + /// - Parameters: + /// - message: The message will be log + /// - file: The file name which the method called by. + /// - function: The function name which log method called in. + /// - line: The line number of file + /// - tag: The external tags which provide more information. + static func errorLog(_ message: @autoclosure () -> String, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil) { + DDLogError(message(), file: file, function: function, line: line, tag: tag) + } + + /** File Logger variable */ + let fileLogger = DDFileLogger() + + /// default instance of Logger + static let `default` = Logger() + + /// Default Constructor for Logger + /// Set Log Level depend on Scheme Configuration + /// Note: Debug Configuration level is all, otherwise leve is waring + init() { + + #if DEBUG + dynamicLogLevel = DDLogLevel.all + #else + dynamicLogLevel = DDLogLevel.warning + #endif + + if let ddttyLogger = DDTTYLogger.sharedInstance { + DDLog.add(ddttyLogger) + } + + DDLog.add(DDOSLogger.sharedInstance) + + fileLogger.rollingFrequency = TimeInterval(60 * 60 * 24) // 24 hours + fileLogger.logFileManager.maximumNumberOfLogFiles = 3 + DDLog.add(fileLogger) + + } + +} + +/// Load Logger +private let logger = Logger.default diff --git a/DutchNews/Classes/Utilites/RxHeadlinesDataSource.swift b/DutchNews/Classes/Utilites/RxHeadlinesDataSource.swift new file mode 100644 index 0000000..46d9708 --- /dev/null +++ b/DutchNews/Classes/Utilites/RxHeadlinesDataSource.swift @@ -0,0 +1,45 @@ +// +// RxHeadlinesDataSource.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/25/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxDataSources +import RxCocoa + +/// Helper Class for fixing the issue with `MagazineLayout` +class RxHeadlinesDataSource: RxCollectionViewSectionedReloadDataSource where T: SectionModelType { + + override func collectionView(_ collectionView: UICollectionView, observedEvent: Event) { + Binder(self) { dataSource, element in + + let indices = (0..= element.count }) + let reloadIndicies = indices.filter { $0 >= element.count } + collectionView.insertSections(insertIndicies, animationStyle: .fade) + collectionView.reloadSections(reloadIndicies, animationStyle: .none) + } + + dataSource.setSections(element) + }, completion: { (finish) in + guard finish else { + return + } + collectionView.collectionViewLayout.invalidateLayout() + }) + + }.on(observedEvent) + } + +} diff --git a/DutchNews/Classes/Utilites/ViewControllerFactory/Screen.swift b/DutchNews/Classes/Utilites/ViewControllerFactory/Screen.swift new file mode 100644 index 0000000..bdd1b2c --- /dev/null +++ b/DutchNews/Classes/Utilites/ViewControllerFactory/Screen.swift @@ -0,0 +1,14 @@ +// +// Screen.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/25/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +protocol Screen { + + func screenIdentifier() -> String +} diff --git a/DutchNews/Classes/Utilites/ViewControllerFactory/ScreenEnum.swift b/DutchNews/Classes/Utilites/ViewControllerFactory/ScreenEnum.swift new file mode 100644 index 0000000..fab4b0f --- /dev/null +++ b/DutchNews/Classes/Utilites/ViewControllerFactory/ScreenEnum.swift @@ -0,0 +1,32 @@ +// +// ScreenEnum.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/25/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +enum ScreenName: Screen { + + case headlines + case pages + case detail + case search + + func screenIdentifier() -> String { + switch self { + case .headlines: + return HeadlinesViewController.className + case .pages: + return ArticlePageViewController.className + case .detail: + return ArticleDetailViewController.className + case .search: + return HeadlineSearchViewController.className + } + } + +} diff --git a/DutchNews/Classes/Utilites/ViewControllerFactory/ViewControllerFactory.swift b/DutchNews/Classes/Utilites/ViewControllerFactory/ViewControllerFactory.swift new file mode 100644 index 0000000..a1d9464 --- /dev/null +++ b/DutchNews/Classes/Utilites/ViewControllerFactory/ViewControllerFactory.swift @@ -0,0 +1,24 @@ +// +// ViewControllerFactory.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/25/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +protocol ViewControllerFactory: class { + + func makeRootViewController() -> UINavigationController? + func makeHeadlinesViewController() throws -> HeadlinesViewController + func makeHeadlinesSearchViewController() throws -> UISearchController + func makePageViewController(selected: Int) throws -> ArticlePageViewController + func makeArticleDetailViewController() throws -> ArticleDetailViewController + +} + +protocol ViewControllerFactoryable { + var controllerFactory: ViewControllerFactory? { get set } +} diff --git a/DutchNews/Classes/Utilites/ViewControllerFactory/ViewModelViewControllerFactory.swift b/DutchNews/Classes/Utilites/ViewControllerFactory/ViewModelViewControllerFactory.swift new file mode 100644 index 0000000..f986354 --- /dev/null +++ b/DutchNews/Classes/Utilites/ViewControllerFactory/ViewModelViewControllerFactory.swift @@ -0,0 +1,88 @@ +// +// ViewModelViewControllerFactory.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/25/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +final class ViewModelViewControllerFactory: ViewControllerFactory { + + enum Error: Swift.Error { + case notFound + } + + let storyboard: UIStoryboard + + init(storyboard: UIStoryboard) { + self.storyboard = storyboard + } + + func makeRootViewController() -> UINavigationController? { + return self.storyboard.instantiateInitialViewController() as? UINavigationController + } + + func makeHeadlinesViewController() throws -> HeadlinesViewController { + + guard let vc: HeadlinesViewController = makeViewController(forScreen: ScreenName.headlines) else { + throw Error.notFound + } + + vc.viewModel = AppDIContainer.headlinesViewModel + vc.controllerFactory = self + vc.searchController = try? makeHeadlinesSearchViewController() + + return vc + } + + func makePageViewController(selected: Int) throws -> ArticlePageViewController { + guard let vc: ArticlePageViewController = makeViewController(forScreen: ScreenName.pages) else { + throw Error.notFound + } + + vc.viewModel = AppDIContainer.articlePagesViewModel + vc.viewModel?.selectedIndex.accept(selected) + vc.controllerFactory = self + + return vc + } + + func makeArticleDetailViewController() throws -> ArticleDetailViewController { + guard let vc: ArticleDetailViewController = makeViewController(forScreen: ScreenName.detail) else { + throw Error.notFound + } + + return vc + } + + func makeHeadlinesSearchViewController() throws -> UISearchController { + + guard let vc: HeadlineSearchViewController = makeViewController(forScreen: ScreenName.search) else { + throw Error.notFound + } + + vc.viewModel = AppDIContainer.headlineSearchViewModel + vc.controllerFactory = self + + let searchController = UISearchController(searchResultsController: vc) + vc.searchController = searchController + searchController.searchResultsUpdater = vc + searchController.obscuresBackgroundDuringPresentation = true + searchController.searchBar.placeholder = "search_headline_title".localized + + return searchController + } + + private func makeViewController(forScreen screen: Screen) -> T? { + + guard let vc = storyboard.instantiateViewController(identifier: screen.screenIdentifier()) as? T else { + return nil + } + + return vc + } + +} diff --git a/DutchNews/Classes/ViewControllers/ArticleDetailViewController.swift b/DutchNews/Classes/ViewControllers/ArticleDetailViewController.swift new file mode 100644 index 0000000..d393755 --- /dev/null +++ b/DutchNews/Classes/ViewControllers/ArticleDetailViewController.swift @@ -0,0 +1,147 @@ +// +// ArticleDetailViewController.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/23/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit +import RxSwift +import RxCocoa +import WebKit +import MaterialComponents.MaterialColor +import MXParallaxHeader +import AVFoundation + +class ArticleDetailViewController: UIViewController, AlertableView { + + @IBOutlet weak var containerScrollView: UIScrollView! + @IBOutlet weak var contentView: WKWebView! + + @IBOutlet weak var loadingIndicator: MDCActivityIndicator! + + lazy var headerView: ArticleDetailHeaderView = { + let view = ArticleDetailHeaderView.fromNib() + return view + }() + + var viewModel: ArticleViewModel? + + let disposeBag = DisposeBag() + + deinit { + viewModel = nil + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupViews() + + viewModel?.output.asObservable().bind(onNext: {[weak self] in + self?.headerView.config(content: $0) + self?.updateHeaderSize() + }).disposed(by: disposeBag) + + viewModel?.buildURLContent() + .observeOn(MainScheduler.asyncInstance) + .bind(onNext: {[weak contentView] (request) in + contentView?.load(request) + }).disposed(by: disposeBag) + + // Do any additional setup after loading the view. + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewDidAppear(animated) + contentView.reload() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + contentView.stopLoading() + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: UI Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + func setupViews() { + + setupHeaderView() + loadingIndicator.indicatorMode = .determinate + loadingIndicator.cycleColors = [.black] + loadingIndicator.sizeToFit() + loadingIndicator.progress = 0 + loadingIndicator.isHidden = true + + observerContentViewLoading() + + } + + func observerContentViewLoading() { + + let didStart = contentView.rx.didStartLoad.map { _ in true } + let didFinish = contentView.rx.didFinishLoad.map { _ in false } + let didError = contentView.rx.didFailLoad.map { _ in false } + + Observable.of(didStart,didFinish,didError) + .merge().observeOn(MainScheduler.instance) + .bind {[weak loadingIndicator] in + + loadingIndicator?.isHidden = !$0 + }.disposed(by: disposeBag) + + contentView.rx.estimatedProgress.observeOn(MainScheduler.instance) + .map { Float($0) } + .bind {[weak loadingIndicator] in + loadingIndicator?.setProgress($0, animated: false) + }.disposed(by: disposeBag) + } + + func setupHeaderView() { + containerScrollView.parallaxHeader.view = headerView + containerScrollView.parallaxHeader.mode = .fill + containerScrollView.parallaxHeader.minimumHeight = 70 + + let height = AVMakeRect(aspectRatio: CGSize(width: 16, height: 9), + insideRect: view.bounds).size.height + + containerScrollView.parallaxHeader.height = height + } + + func updateHeaderSize() { + + headerView.layoutIfNeeded() + + let size = headerView.systemLayoutSizeFitting(headerView.bounds.size, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel) + + if let minHeight = headerView.titleLabel.superview?.bounds.height { + containerScrollView.parallaxHeader.minimumHeight = minHeight + 16 + } + + let height = AVMakeRect(aspectRatio: CGSize(width: 16, height: 9), + insideRect: view.bounds).size.height + + containerScrollView.parallaxHeader.height = max(size.height, height) + + } + +} diff --git a/DutchNews/Classes/ViewControllers/ArticlesPageViewController.swift b/DutchNews/Classes/ViewControllers/ArticlesPageViewController.swift new file mode 100644 index 0000000..345d4b7 --- /dev/null +++ b/DutchNews/Classes/ViewControllers/ArticlesPageViewController.swift @@ -0,0 +1,129 @@ +// +// ArticlesPageViewController.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/23/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import Pageboy +import RxSwift +import RxCocoa + +class ArticlePageViewController: PageboyViewController, AlertableView, ViewControllerFactoryable { + + var viewModel: ArticlesPageViewModel? + + private var selectedIndex = 0 + + let disposeBag = DisposeBag() + + var controllerFactory: ViewControllerFactory? + + deinit { + viewModel = nil + controllerFactory = nil + } + + override func viewDidLoad() { + super.viewDidLoad() + interPageSpacing = 8.0 + self.dataSource = self + + bindViewModel() + loadContentsIfNeeded() + // Do any additional setup after loading the view. + + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + + func updateLayouts(onState state: ViewModelState) { + switch state { + + case .loaded: + self.reloadData() + case .error(let error): + + let message: String + if let err = error as? URLError { + message = err.localizedDescription + }else { + message = error.localizedDescription + } + + presentAlertView(withMessage: message, + actionTitle: "retry".localized) {[weak self] in + self?.loadContentsIfNeeded() + } + default: + break + } + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: ViewModel + // MARK: - + //////////////////////////////////////////////////////////////// + + func bindViewModel() { + + guard let viewModel = self.viewModel else { + return + } + + bind(viewModel: viewModel) + } + + func bind(viewModel: ArticlesPageViewModel) { + + viewModel.selectedIndex.asDriver() + .drive(onNext: {[weak self] in + self?.selectedIndex = $0 ?? 0 + }).disposed(by: disposeBag) + + viewModel.state.drive(onNext: {[weak self] (state) in + self?.updateLayouts(onState: state) + }).disposed(by: disposeBag) + + } + + func loadContentsIfNeeded() { + viewModel?.fetchArticles() + } + +} + +extension ArticlePageViewController: PageboyViewControllerDataSource { + + func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { + return viewModel?.count ?? 0 + } + + func viewController(for pageboyViewController: PageboyViewController, + at index: PageboyViewController.PageIndex) -> UIViewController? { + + guard let vc = try? controllerFactory?.makeArticleDetailViewController() else { + return nil + } + + vc.viewModel = viewModel?[index] + + return vc + } + + func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { + return .at(index: selectedIndex) + } + +} diff --git a/DutchNews/Classes/ViewControllers/HeadlineSearchViewController.swift b/DutchNews/Classes/ViewControllers/HeadlineSearchViewController.swift new file mode 100644 index 0000000..fa796d3 --- /dev/null +++ b/DutchNews/Classes/ViewControllers/HeadlineSearchViewController.swift @@ -0,0 +1,240 @@ +// +// HeadlineSearchViewController.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/26/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import UIKit +import RxCocoa +import RxSwift +import RxDataSources +import PureLayout +import MaterialComponents + +class HeadlineSearchViewController: UIViewController, AlertableView { + + @IBOutlet weak var tableView: UITableView! + + @IBOutlet weak var loadingIndicator: MDCProgressView! + + let disposeBag = DisposeBag() + + lazy var dataSource: RxTableViewSectionedReloadDataSource = { + return self.buildDataSource() + }() + + var viewModel: ArticlesSearchViewModel? + + weak var searchController: UISearchController? + + var controllerFactory: ViewControllerFactory? + + var inSearching: Bool { + (searchController?.isActive ?? false) && + !(searchController?.searchBar.text.isEmpty ?? true) + } + + deinit { + viewModel = nil + searchController = nil + controllerFactory = nil + } + + override func viewDidLoad() { + super.viewDidLoad() + setupLayouts() + bindViewModel() + // Do any additional setup after loading the view. + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: UI Methods + // MARK: - + //////////////////////////////////////////////////////////////// + func setupLayouts() { + setupTableView() + + loadingIndicator.mode = .indeterminate + loadingIndicator.trackTintColor = .clear + loadingIndicator.progressTintColor = .black + loadingIndicator.stopAnimating() + loadingIndicator.isHidden = true + + } + + fileprivate static let cellId = "ResultCellId" + + func setupTableView() { + tableView.register(HeadlineSearchTableViewCell.nib(), forCellReuseIdentifier: Self.cellId) + tableView.estimatedRowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.separatorColor = nil + } + + func updateLayoutsBase(onState state: ViewModelState) { + switch state { + case .loading(isRefreshing: let isRefreshing) where isRefreshing == false : + loadingIndicator.startAnimating() + loadingIndicator.isHidden = false + + case .loaded: + + if dataSource.sectionModels.flatMap({ $0.items }).count == 0, inSearching { + presentAlertView(withMessage: "no_result_found".localized) + } + + fallthrough + case .idle: + + loadingIndicator.stopAnimating() + loadingIndicator.isHidden = true + + case .error(let error): + loadingIndicator.stopAnimating() + + let message: String + if let err = error as? URLError { + message = err.localizedDescription + }else { + message = error.localizedDescription + } + + presentAlertView(withMessage: message, + actionTitle: "retry".localized) {[weak self] in + self?.searchAgain() + } + default: + break + } + } + + ///////////////////////////////////////////////////////////// + // MARK: - + // MARK: View Model Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + func bindViewModel() { + + guard let viewModel = self.viewModel else { + return + } + + bind(viewModel: viewModel) + } + + func bind(viewModel: ArticlesSearchViewModel) { + + viewModel.output.map { + return $0.map { SectionType(model: "search_result_title", items: $0.items) } + } .drive(tableView.rx.items(dataSource: dataSource)).disposed(by: disposeBag) + + tableView.rx.itemSelected.bind {[weak viewModel] (indexPath) in + viewModel?.didSelect(articleAtIndex: indexPath) + }.disposed(by: disposeBag) + + viewModel.state.drive(onNext: {[weak self] (state) in + self?.updateLayoutsBase(onState: state) + }).disposed(by: disposeBag) + + searchController?.searchBar.rx.text + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .bind(onNext: {[weak viewModel] (keyword) in + viewModel?.searchArticles(keyword: keyword ?? "") + }).disposed(by: disposeBag) + + searchController?.searchBar.rx.cancelButtonClicked.bind(onNext: { [weak viewModel] in + viewModel?.searchArticles(keyword: "") + }).disposed(by: disposeBag) + + viewModel.selectedItem.observeOn(MainScheduler.instance) + .filter { $0 != nil }.map { $0! } + .bind {[weak self] in + self?.navigateToDetail(with: $0) + }.disposed(by: disposeBag) + } + +} + +extension HeadlineSearchViewController: UISearchControllerDelegate, UISearchResultsUpdating { + + func updateSearchResults(for searchController: UISearchController) { + + } + +} + +//////////////////////////////////////////////////////////////// +// MARK: - +// MARK: Helper Methods +// MARK: - +//////////////////////////////////////////////////////////////// + +extension HeadlineSearchViewController { + + typealias SectionType = ArticlesSearchViewModel.T + + func buildDataSource() -> RxTableViewSectionedReloadDataSource { + + return .init(configureCell: {[weak self] (_, tableView, indexPath, viewModel) -> UITableViewCell in + + guard let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellId, + for: indexPath) as? HeadlineSearchTableViewCell else { + return UITableViewCell() + } + + self?.config(cell: cell, viewModel: viewModel) + return cell + }, + titleForHeaderInSection: { (dataSource, section) -> String? in + dataSource[section].model.localized + }) + } + + private func config(cell: HeadlineSearchTableViewCell, + viewModel: ArticleViewModel) { + + viewModel.output + .drive(onNext: {[weak cell] viewModel in + cell?.config(viewModel: viewModel) + }).disposed(by: cell.disposeBag) + + } + + func search(keyword: String?) { + viewModel?.searchArticles(keyword: keyword ?? "") + } + + func searchAgain() { + self.search(keyword: searchController?.searchBar.text) + } + + func navigateToDetail(with viewModel: ArticleViewModel) { + do { + guard let vc = try controllerFactory?.makeArticleDetailViewController() else { + return + } + + vc.viewModel = viewModel + + self.presentingViewController?.navigationController?.show(vc, sender: false) + + }catch { + presentAlertView(withMessage: error.localizedDescription) + } + } + +} diff --git a/DutchNews/Classes/ViewControllers/HeadlinesViewController+DataSource.swift b/DutchNews/Classes/ViewControllers/HeadlinesViewController+DataSource.swift new file mode 100644 index 0000000..6c22db9 --- /dev/null +++ b/DutchNews/Classes/ViewControllers/HeadlinesViewController+DataSource.swift @@ -0,0 +1,77 @@ +// +// HeadlinesViewController+DataSource.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit +import RxCocoa +import RxSwift +import RxDataSources + +extension HeadlinesViewController { + + typealias SectionType = ArticlesViewModel.T + + func buildDataSource() -> RxHeadlinesDataSource { + + return RxHeadlinesDataSource(configureCell: {[weak self] (dataSource, collectionView, indexPath, _) -> UICollectionViewCell in + + guard let `self` = self else { + return HeadlineBaseCollectionViewCell() + } + + let item = dataSource[indexPath] + + let reuseId = self.reuseItentifier(forCellAt: indexPath, item: item.model).id + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseId, for: indexPath) + + self.fill(cell: cell, withArticle: item) + + return cell + }) + } + + /// <#Description#> + /// - Parameter index: <#index description#> + private func reuseItentifier(forCellAt index: IndexPath, item: Article) -> HeadlinesCellIdentifier { + + switch (index.section, index.item) { + case (_,0): + return .main + case (_,3) where item.type == .mock : + return .web + case (_,1), + (_,2): + return .halfWidth + default: + return .row + } + } + + private func fill(cell: UICollectionViewCell, withArticle article: ArticleViewModel) { + + switch (cell) { + case (let cell as ArticleWebContainerCollectionViewCell) where article.model.type == .mock : + + if cell.contentLabel.text == nil, + let content = article.model.content { + + cell.webView.loadHTMLString(content, baseURL: nil) + cell.contentLabel.text = content + + } + case (let cell as HeadlineBaseCollectionViewCell): + article.output + .drive(onNext: {[weak cell] viewModel in + cell?.config(viewModel: viewModel) + }).disposed(by: cell.disposeBag) + default: + break + } + } + +} diff --git a/DutchNews/Classes/ViewControllers/HeadlinesViewController+MagazineLayout.swift b/DutchNews/Classes/ViewControllers/HeadlinesViewController+MagazineLayout.swift new file mode 100644 index 0000000..12c3276 --- /dev/null +++ b/DutchNews/Classes/ViewControllers/HeadlinesViewController+MagazineLayout.swift @@ -0,0 +1,59 @@ +// +// HeadlinesViewController+MagazineLayout.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit +import MagazineLayout + +extension HeadlinesViewController: UICollectionViewDelegateMagazineLayout { + + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForFooterInSectionAtIndex index: Int) -> MagazineLayoutFooterVisibilityMode { + + layoutConfiguration.visibleMode(forFooterAt: index) + } + + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForHeaderInSectionAtIndex index: Int) -> MagazineLayoutHeaderVisibilityMode { + layoutConfiguration.visibleMode(forHeaderAt: index) + } + + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + visibilityModeForBackgroundInSectionAtIndex index: Int) -> MagazineLayoutBackgroundVisibilityMode { + layoutConfiguration.visibleMode(forBackground: index) + } + + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeModeForItemAt indexPath: IndexPath) -> MagazineLayoutItemSizeMode { + return layoutConfiguration.itemSizeMode(forItemAt: indexPath) + } + + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + horizontalSpacingForItemsInSectionAtIndex index: Int) -> CGFloat { + return layoutConfiguration.horizontalSpacing(forElementsInSectionAtIndex: index) + } + + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + verticalSpacingForElementsInSectionAtIndex index: Int) -> CGFloat { + return layoutConfiguration.verticalSpacing(forElementsInSectionAtIndex: index) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetsForSectionAtIndex index: Int) -> UIEdgeInsets { + layoutConfiguration.insets(forSectionAtIndex: index) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetsForItemsInSectionAtIndex index: Int) -> UIEdgeInsets { + layoutConfiguration.insets(forItemsInSectionAtIndex: index) + } +} diff --git a/DutchNews/Classes/ViewControllers/HeadlinesViewController.swift b/DutchNews/Classes/ViewControllers/HeadlinesViewController.swift new file mode 100644 index 0000000..d5a1a0f --- /dev/null +++ b/DutchNews/Classes/ViewControllers/HeadlinesViewController.swift @@ -0,0 +1,244 @@ +// +// HeadlinesViewController.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import UIKit +import MagazineLayout +import RxCocoa +import RxSwift +import RxDataSources +import PureLayout +import MaterialComponents.MDCActivityIndicator + +class HeadlinesViewController: UIViewController { + + enum HeadlinesCellIdentifier: String { + case main + case halfWidth + case web + case row + + var id: String { + return self.rawValue + } + } + + @IBOutlet weak var collectionView: UICollectionView! + + var collectionLayout: MagazineLayout? { + return collectionView?.collectionViewLayout as? MagazineLayout + } + + lazy var refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.tintColor = .black + return refreshControl + }() + + @IBOutlet weak var loadingIndicator: MDCActivityIndicator! + + var layoutConfiguration: HeadlineLayoutConfiguration = ArticleHeadlineLayoutConfiguration() { + didSet { + collectionView.reloadData() + } + } + + let disposeBag = DisposeBag() + + lazy var dataSource: RxHeadlinesDataSource = { + return self.buildDataSource() + }() + + var viewModel: ArticlesViewModel? = HeadlinesViewModel(useCase: AppDIContainer.headlineFetchingUseCase) + + var controllerFactory: ViewControllerFactory? + + var searchController: UISearchController? + + deinit { + viewModel = nil + searchController = nil + controllerFactory = nil + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + + setupLayouts() + + bindViewModels() + loadContentsIfNeeded() + + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: UI Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + func setupLayouts() { + setupColletionView() + + loadingIndicator.indicatorMode = .indeterminate + loadingIndicator.cycleColors = [.black] + loadingIndicator.sizeToFit() + loadingIndicator.stopAnimating() + loadingIndicator.isHidden = true + + setupSearchView() + } + + func setupColletionView() { + + collectionView.register(MainArticleCollectionViewCell.nib(), + forCellWithReuseIdentifier: HeadlinesCellIdentifier.main.id) + collectionView.register(ArticleRowCollectionViewCell.nib(), + forCellWithReuseIdentifier: HeadlinesCellIdentifier.row.id) + collectionView.register(HalfWidthArticleCollectionViewCell.nib(), + forCellWithReuseIdentifier: HeadlinesCellIdentifier.halfWidth.id) + collectionView.register(ArticleWebContainerCollectionViewCell.nib(), + forCellWithReuseIdentifier: HeadlinesCellIdentifier.web.id) + + collectionView.delegate = self + collectionView.refreshControl = refreshControl + + } + + func updateLayoutsBase(onState state: ViewModelState) { + switch state { + case .loading(isRefreshing: let isRefreshing) where isRefreshing == false : + loadingIndicator.startAnimating() + loadingIndicator.isHidden = false + refreshControl.isEnabled = false + case .loaded: + guard refreshControl.isRefreshing else { + fallthrough + } + refreshControl.endRefreshing() + fallthrough + case .idle: + loadingIndicator.stopAnimating() + loadingIndicator.isHidden = true + refreshControl.isEnabled = true + + case .error(let error): + refreshControl.endRefreshing() + loadingIndicator.stopAnimating() + + let message: String + if let err = error as? URLError { + message = err.localizedDescription + }else { + message = error.localizedDescription + } + + presentAlertView(withMessage: message, + actionTitle: "retry".localized) {[weak self] in + self?.loadContentsIfNeeded() + } + default: + break + } + } + + func setupSearchView() { + + navigationItem.searchController = searchController + definesPresentationContext = true + } + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: View Model Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + func bindViewModels() { + + guard let viewModel = self.viewModel else { + return + } + + bind(viewModel: viewModel) + } + + func bind(viewModel: ArticlesViewModel) { + + viewModel.output.drive(collectionView.rx.items(dataSource: dataSource)).disposed(by: disposeBag) + + // RxSwift assigned another delelgate object after running the upper code + // we have to make sure that current vc present as delegate + collectionView.delegate = self + + viewModel.state.drive(onNext: {[weak self] (state) in + self?.updateLayoutsBase(onState: state) + }).disposed(by: disposeBag) + + refreshControl.rx.controlEvent(.valueChanged) + .bind { [weak self] _ in + self?.loadContentsIfNeeded() + }.disposed(by: disposeBag) + + viewModel.selectedIndex.observeOn(MainScheduler.instance) + .filter { $0 != nil }.map { $0! } + .bind {[weak self] in + + self?.navigateToPages(withIndex: $0) + + }.disposed(by: disposeBag) + } + + func loadContentsIfNeeded() { + guard refreshControl.isRefreshing else { + viewModel?.fetchArticles() + return + } + + viewModel?.refreshArticles() + } + + func navigateToPages(withIndex index: Int) { + do { + guard let vc = try controllerFactory?.makePageViewController(selected: index) else { + return + } + + self.navigationController?.show(vc, sender: false) + + }catch { + presentAlertView(withMessage: error.localizedDescription) + } + } + +} + +extension HeadlinesViewController: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + collectionView.deselectItem(at: indexPath, animated: true) + + let item = dataSource[indexPath] + viewModel?.didSelect(article: item) + + } +} + +extension HeadlinesViewController: AlertableView {} +extension HeadlinesViewController: ViewControllerFactoryable {} diff --git a/DutchNews/Classes/ViewModels/Abstract/ArticleViewModel.swift b/DutchNews/Classes/ViewModels/Abstract/ArticleViewModel.swift new file mode 100644 index 0000000..d5dd78c --- /dev/null +++ b/DutchNews/Classes/ViewModels/Abstract/ArticleViewModel.swift @@ -0,0 +1,92 @@ +// +// ArticleViewModel.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +//swiftlint:disable type_name + +/// Abstract `ArtileViewModel` +protocol ArticleViewModel: class { + + typealias T = Article + + var state: Driver { get } + + var output: Driver { get } + + var model: T { get } + + func buildURLContent() -> Observable + +} +//swiftlint:enable type_name + +extension ArticleViewModel { + + func buildURLContent() -> Observable { + return output.map({ + URLRequest(url: $0.url, + cachePolicy: .reloadRevalidatingCacheData, + timeoutInterval: 30.0) + }) .asObservable() + } +} + +extension ArticleViewModel where Self: Equatable { + + static func ==(lhs: ArticleViewModel, rhs: ArticleViewModel) -> Bool { + guard type(of: lhs) == type(of: rhs) else { + return false + } + return lhs.model.publishedAt == rhs.model.publishedAt && + lhs.model.url == rhs.model.url && + lhs.model.title == rhs.model.title + } +} + +func ==(lhs: ArticleViewModel, rhs: ArticleViewModel) -> Bool { + + guard type(of: lhs) == type(of: rhs) else { + return false + } + + return lhs.model.publishedAt == rhs.model.publishedAt && + lhs.model.url == rhs.model.url && + lhs.model.title == rhs.model.title +} + +/// `ArticleRepresentable` is representive of article output +protocol ArticleRepresentable { + + var title: String { get } + var author: String? { get } + var description: String? { get } + + var source: String? { get } + + var url: URL { get } + + var urlToImage: URL? { get } + + var publishedAt: String { get } + + var content: String? { get } + + var type: ArticleType { get } + +} + +func ==(lhs: ArticleRepresentable, rhs: ArticleRepresentable) -> Bool { + + guard type(of: lhs) == type(of: rhs) else { + return false + } + return lhs.publishedAt == rhs.publishedAt && lhs.url == rhs.url && lhs.title == rhs.title +} diff --git a/DutchNews/Classes/ViewModels/Abstract/ArticlesPageViewModel.swift b/DutchNews/Classes/ViewModels/Abstract/ArticlesPageViewModel.swift new file mode 100644 index 0000000..001b9bd --- /dev/null +++ b/DutchNews/Classes/ViewModels/Abstract/ArticlesPageViewModel.swift @@ -0,0 +1,44 @@ +// +// ArticlesPageViewModel.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa +import RxDataSources + +/// ArticleViewModel Interface +protocol ArticlesPageViewModel: ArticlesViewModel { + + var count: Int { get } + + var currentLoadingProgress: BehaviorRelay { get } + + func viewModel(atIndex index: Int) -> T.Item? + + subscript(index: Int) -> T.Item? { get } +} + +// MARK: - Implemented optional methods +extension ArticlesPageViewModel { + + var output: Driver<[T]> { + return .empty() + } + + func refreshArticles() { + + } + + func didSelect(article: T.Item) { + + } + + func didSelect(articleAtIndex: IndexPath) { + + } +} diff --git a/DutchNews/Classes/ViewModels/Abstract/ArticlesViewModel.swift b/DutchNews/Classes/ViewModels/Abstract/ArticlesViewModel.swift new file mode 100644 index 0000000..00337c7 --- /dev/null +++ b/DutchNews/Classes/ViewModels/Abstract/ArticlesViewModel.swift @@ -0,0 +1,41 @@ +// +// ArticlesViewModel.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa +import RxDataSources + +/// ArticleViewModel Interface +protocol ArticlesViewModel: class { + + typealias T = SectionModel + + var state: Driver { get } + + var output: Driver<[T]> { get } + + var selectedIndex: BehaviorRelay { get set } + + func fetchArticles() + + func refreshArticles() + + func didSelect(article: T.Item) + + func didSelect(articleAtIndex: IndexPath) + +} + +protocol ArticlesSearchViewModel: ArticlesViewModel { + + var selectedItem: BehaviorRelay { get } + + func searchArticles(keyword: String) + +} diff --git a/DutchNews/Classes/ViewModels/ArticleDetailViewModel.swift b/DutchNews/Classes/ViewModels/ArticleDetailViewModel.swift new file mode 100644 index 0000000..2618ec8 --- /dev/null +++ b/DutchNews/Classes/ViewModels/ArticleDetailViewModel.swift @@ -0,0 +1,74 @@ +// +// ArticleDetailViewModel.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +final class ArticleDetailViewModel: ArticleViewModel { + + var state: Driver { + return .empty() + } + + var output: Driver { + return _output.asDriver() + } + + private var _output: BehaviorRelay + + var model: T { + didSet { + guard model != oldValue else { + return + } + _output.accept(Self.convert(model: model)) + } + } + + init(model: T) { + self.model = model + _output = BehaviorRelay(value: Self.convert(model: model)) + } + + private static func convert(model: T) -> ArticleRepresentable { + + let dateFormatter = DateFormatter.currentZoneFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + + return ArticleDetailViewModel(title: model.title, + author: model.author, + description: model.title, + source: model.source.name, + url: model.url, + urlToImage: model.urlToImage, + publishedAt: dateFormatter.string(from: model.publishedAt), + content: model.content, type: model.type) + } + +} + +private extension ArticleDetailViewModel { + + struct ArticleDetailViewModel: ArticleRepresentable { + + var title: String + var author: String? + var description: String? + var source: String? + var url: URL + var urlToImage: URL? + var publishedAt: String + var content: String? + + var type: ArticleType + + } + +} diff --git a/DutchNews/Classes/ViewModels/ArticleDetailsPageViewModel.swift b/DutchNews/Classes/ViewModels/ArticleDetailsPageViewModel.swift new file mode 100644 index 0000000..59d5fd8 --- /dev/null +++ b/DutchNews/Classes/ViewModels/ArticleDetailsPageViewModel.swift @@ -0,0 +1,92 @@ +// +// ArticleDetailsPageView.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +final class ArticleDetailsPageViewModel: ArticlesPageViewModel { + + private var statePublisher: BehaviorRelay + + var state: Driver { + return statePublisher.asDriver { + return .just(.error($0) ) + }.distinctUntilChanged() + } + + private var items: [T.Item] = [] { + didSet { + + } + } + + var count: Int { + return items.count + } + + var selectedIndex: BehaviorRelay + + var currentLoadingProgress: BehaviorRelay + + let useCase: ArticlesUseCase + + let disposeBag = DisposeBag() + + init(useCase: ArticlesUseCase) { + self.useCase = useCase + self.selectedIndex = .init(value: 0) + self.currentLoadingProgress = .init(value: 0.0) + self.statePublisher = BehaviorRelay(value: .idle) + } + + func viewModel(atIndex index: Int) -> T.Item? { + + guard let item = items[safe: index] else { + return nil + } + + return item + } + + subscript(index: Int) -> T.Item? { + return viewModel(atIndex: index) + } + + func fetchArticles() { + + statePublisher.accept(.loading(isRefreshing: false)) + + useCase.fetchLocalArticles() + .subscribe {[weak self] event in + + switch event { + case .next(let newItems): + guard let `self` = self else { + break + } + + let mapped = newItems.map { + ArticleDetailViewModel(model: $0) + } + + self.items = mapped + + self.statePublisher.accept(.loaded) + + case .error(let error): + self?.statePublisher.accept(.error(error)) + self?.statePublisher.accept(.idle) + case .completed: + self?.statePublisher.accept(.idle) + } + + }.disposed(by: disposeBag) + } + +} diff --git a/DutchNews/Classes/ViewModels/HeadlineCellViewModel.swift b/DutchNews/Classes/ViewModels/HeadlineCellViewModel.swift new file mode 100644 index 0000000..b9e3c2e --- /dev/null +++ b/DutchNews/Classes/ViewModels/HeadlineCellViewModel.swift @@ -0,0 +1,87 @@ +// +// HeadlineCellViewModel.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +class HeadlineCellViewModel: ArticleViewModel { + + var state: Driver { + return .empty() + } + + var output: Driver { + return _output.asDriver() + } + + private var _output: BehaviorRelay + + var model: T { + didSet { + guard model != oldValue else { + return + } + _output.accept(Self.convert(model: model)) + } + } + + init(model: T) { + self.model = model + _output = BehaviorRelay(value: Self.convert(model: model)) + } + + private static func convert(model: T) -> ArticleRepresentable { + + let dateFormatter = DateFormatter.currentZoneFormatter() + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .short + + return HeadlineCellOutput(title: model.title, + author: model.author, + description: model.title, + source: model.source.name, + url: model.url, + urlToImage: model.urlToImage, + publishedAt: dateFormatter.string(from: model.publishedAt), + content: model.content, type: model.type) + } + +} + +extension HeadlineCellViewModel: Hashable { + + static func == (lhs: HeadlineCellViewModel, rhs: HeadlineCellViewModel) -> Bool { + return lhs.hashValue == rhs.hashValue + } + + func hash(into hasher: inout Hasher) { + hasher.combine(model.title) + hasher.combine(model.publishedAt) + hasher.combine(model.url) + } +} + +private extension HeadlineCellViewModel { + + struct HeadlineCellOutput: ArticleRepresentable { + + var title: String + var author: String? + var description: String? + var source: String? + var url: URL + var urlToImage: URL? + var publishedAt: String + var content: String? + + var type: ArticleType + + } + +} diff --git a/DutchNews/Classes/ViewModels/HeadlineSearchViewModel.swift b/DutchNews/Classes/ViewModels/HeadlineSearchViewModel.swift new file mode 100644 index 0000000..5e4e3b7 --- /dev/null +++ b/DutchNews/Classes/ViewModels/HeadlineSearchViewModel.swift @@ -0,0 +1,136 @@ +// +// HeadlineSearchViewModel.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +final class HeadlineSearchViewModel: ArticlesSearchViewModel { + + var selectedIndex: BehaviorRelay + + private(set) var selectedItem: BehaviorRelay = BehaviorRelay(value: nil) + + private var statePublisher: BehaviorRelay + + var state: Driver { + return statePublisher.asDriver { + return .just(.error($0) ) + }.distinctUntilChanged() + } + + private var outputPublisher: BehaviorRelay<[T]> + + private var items: [T.Item] = [] { + didSet { + outputPublisher.accept([T(model: "Headlines", items: items)]) + } + } + + var output: Driver<[T]> { + outputPublisher.asDriver { _ in return .never() } + } + + let useCase: HeadlinesUseCases + + let disposeBag = DisposeBag() + + private init(useCase: HeadlinesUseCases, + state: BehaviorRelay, + output: BehaviorRelay<[T]>) { + + self.useCase = useCase + self.statePublisher = state + self.outputPublisher = output + self.selectedIndex = BehaviorRelay(value: nil) + + } + + required convenience init(useCase: HeadlinesUseCases) { + self.init(useCase: useCase, + state: .init(value: .idle), + output: .init(value: [])) + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Article View Model Implementation + // MARK: - + //////////////////////////////////////////////////////////////// + + func searchArticles(keyword: String) { + searchFromRepository(keyword: keyword) + } + + func fetchArticles() { + + } + + func refreshArticles() { + + } + + func didSelect(article: T.Item) { + let datas = items + + guard let index = datas.firstIndex(where: { $0 == article }), + index != 3 else { + return + } + + self.selectedIndex.accept(index) + self.selectedItem.accept(article) + } + + func didSelect(articleAtIndex index: IndexPath) { + let datas = items + guard datas[safe:index.item] != nil else { + return + } + self.selectedIndex.accept(index.item) + self.selectedItem.accept(datas[safe:index.item]) + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Private Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + func searchFromRepository(keyword: String) { + + statePublisher.accept(.loading(isRefreshing: false)) + + useCase.searchInArticle(keyword: keyword) + .subscribe {[weak self] event in + + switch event { + case .next(let newItems): + guard let `self` = self else { + break + } + + let mapped = newItems.map { + HeadlineCellViewModel(model: $0) + } + + self.items = mapped + self.statePublisher.accept(.loaded) + + case .error(let error): + self?.statePublisher.accept(.error(error)) + self?.statePublisher.accept(.idle) + case .completed: + self?.statePublisher.accept(.idle) + } + + }.disposed(by: disposeBag) + + } + +} diff --git a/DutchNews/Classes/ViewModels/HeadlinesViewModel.swift b/DutchNews/Classes/ViewModels/HeadlinesViewModel.swift new file mode 100644 index 0000000..e025e6a --- /dev/null +++ b/DutchNews/Classes/ViewModels/HeadlinesViewModel.swift @@ -0,0 +1,162 @@ +// +// HeadlinesViewModel.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +final class HeadlinesViewModel: ArticlesViewModel { + + var selectedIndex: BehaviorRelay + + private var statePublisher: BehaviorRelay + + var state: Driver { + return statePublisher.asDriver { + return .just(.error($0) ) + }.distinctUntilChanged() + } + + private var outputPublisher: BehaviorRelay<[T]> + + private var items: [T.Item] = [] { + didSet { + outputPublisher.accept([T(model: "Headlines", items: items)]) + } + } + + var output: Driver<[T]> { + outputPublisher.asDriver { _ in return .never() } + } + + let useCase: HeadlinesUseCases + + let disposeBag = DisposeBag() + + private init(useCase: HeadlinesUseCases, + state: BehaviorRelay, + output: BehaviorRelay<[T]>) { + + self.useCase = useCase + self.statePublisher = state + self.outputPublisher = output + self.selectedIndex = BehaviorRelay(value: nil) + + } + + required convenience init(useCase: HeadlinesUseCases) { + self.init(useCase: useCase, + state: .init(value: .idle), + output: .init(value: [])) + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Article View Model Implementation + // MARK: - + //////////////////////////////////////////////////////////////// + + func fetchArticles() { + fetchArticlesFromRepository() + } + + func refreshArticles() { + fetchArticlesFromRepository(isRefreshing: true) + } + + func didSelect(article: T.Item) { + let datas = items + guard var index = datas.firstIndex(where: { $0 == article }), + index != 3 else { + return + } + + if index > 3 { + index -= 1 + } + + self.selectedIndex.accept(index) + } + + func didSelect(articleAtIndex index: IndexPath) { + let datas = items + + // make mutable. + + var index = index + guard index.item != 3, datas[safe:index.item] != nil else { + return + } + + if index.item > 3 { + index.item -= 1 + } + + self.selectedIndex.accept(index.item) + } + + //////////////////////////////////////////////////////////////// + // MARK: - + // MARK: Private Methods + // MARK: - + //////////////////////////////////////////////////////////////// + + func fetchArticlesFromRepository(isRefreshing: Bool = false) { + statePublisher.accept(.loading(isRefreshing: isRefreshing)) + + useCase.fetchArticles() + .subscribe {[weak self] event in + + switch event { + case .next(let newItems): + guard let `self` = self else { + break + } + + var mapped = newItems.map { + HeadlineCellViewModel(model: $0) + } + + guard case let .loading(isRefreshing:isRefreshing) = self.statePublisher.value, + isRefreshing == true else { + + if mapped.count > 3 { + mapped.insert(HeadlineCellViewModel(model: Article.htmlArticle()), + at: 3) + } + self.items = mapped + self.statePublisher.accept(.loaded) + return + } + + let output = mapped.filter { viewModel in + !self.items.contains(where: { $0 == viewModel }) + } + + var result = output + self.items.filter({ $0.model.type == .news }) + + if result.count > 3 { + result.insert(HeadlineCellViewModel(model: Article.htmlArticle()), + at: 3) + } + self.items = result + + self.statePublisher.accept(.loaded) + + case .error(let error): + self?.statePublisher.accept(.error(error)) + self?.statePublisher.accept(.idle) + case .completed: + self?.statePublisher.accept(.idle) + } + + }.disposed(by: disposeBag) + + } + +} diff --git a/DutchNews/Classes/ViewModels/ViewModelState.swift b/DutchNews/Classes/ViewModels/ViewModelState.swift new file mode 100644 index 0000000..5cfdb9e --- /dev/null +++ b/DutchNews/Classes/ViewModels/ViewModelState.swift @@ -0,0 +1,40 @@ +// +// ViewModelState.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +enum ViewModelState { + + case idle + case loading(isRefreshing: Bool) + case loaded + case error(Error) + +} + +extension ViewModelState: Hashable { + + static func == (lhs: ViewModelState, rhs: ViewModelState) -> Bool { + switch (lhs, rhs) { + case (idle,idle), + (loaded,loaded): + return true + case (error(let lhs),error(let rhs)) where type(of: lhs.self) == type(of: rhs.self): + return true + case (loading(isRefreshing: let lValue),loading(isRefreshing: let rValue)) where rValue == lValue: + return true + default: + return false + } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self) + } + +} diff --git a/DutchNews/Classes/Views/AlertView/AlertableView.swift b/DutchNews/Classes/Views/AlertView/AlertableView.swift new file mode 100644 index 0000000..b62b906 --- /dev/null +++ b/DutchNews/Classes/Views/AlertView/AlertableView.swift @@ -0,0 +1,40 @@ +// +// AlertableView.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import MaterialComponents +import UIKit + +/// Abstract `AlertableView` +protocol AlertableView: class { + + /// <#Description#> + /// + /// - Parameters: + /// - message: <#message description#> + /// - actionTitle: <#actionTitle description#> + /// - actionHandler: <#actionHandler description#> + func presentAlert(message: String, actionTitle: String?, actionHandler:@escaping () -> Void) +} + +// Abstract `AlertableView` impelementation +extension AlertableView { + + func presentAlert(message: String, actionTitle: String?, actionHandler:@escaping () -> Void) { + let alert = MDCSnackbarMessage(text: message) + + // if actionTitle has a value, create snackbar action and assign to alert + if let actionTitle = actionTitle { + alert.action = MDCSnackbarMessageAction() + alert.action?.title = actionTitle + alert.action?.handler = actionHandler + } + + MDCSnackbarManager.show(alert) + } +} diff --git a/DutchNews/Classes/Views/Cells/ArticleRowCollectionViewCell.swift b/DutchNews/Classes/Views/Cells/ArticleRowCollectionViewCell.swift new file mode 100644 index 0000000..3aa35b6 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/ArticleRowCollectionViewCell.swift @@ -0,0 +1,46 @@ +// +// ArticleRowCollectionViewCell.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import UIKit + +class ArticleRowCollectionViewCell: HeadlineBaseCollectionViewCell { + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var descriptionLabel: UILabel! + @IBOutlet weak var sourceLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 5.0 + // Initialization code + } + + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.text = nil + descriptionLabel.text = nil + sourceLabel.text = nil + dateLabel.text = nil + imageView.image = #imageLiteral(resourceName: "image-placeHolder") + imageView.cancelCurrentImageLoad() + } + + override func config(viewModel article: ArticleRepresentable) { + titleLabel.text = article.title + descriptionLabel.text = article.description + sourceLabel.text = article.source + imageView.setImage(url: article.urlToImage) + dateLabel.text = article.publishedAt + super.config(viewModel: article) + } + +} diff --git a/DutchNews/Classes/Views/Cells/ArticleRowCollectionViewCell.xib b/DutchNews/Classes/Views/Cells/ArticleRowCollectionViewCell.xib new file mode 100644 index 0000000..05f5796 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/ArticleRowCollectionViewCell.xib @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DutchNews/Classes/Views/Cells/ArticleWebContainerCollectionViewCell.swift b/DutchNews/Classes/Views/Cells/ArticleWebContainerCollectionViewCell.swift new file mode 100644 index 0000000..b892c94 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/ArticleWebContainerCollectionViewCell.swift @@ -0,0 +1,49 @@ +// +// ArticleWebContainerCollectionViewCell.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import UIKit +import WebKit +import PureLayout +import RxCocoa +import RxSwift + +class ArticleWebContainerCollectionViewCell: HeadlineBaseCollectionViewCell { + + @IBOutlet var contentLabel: UILabel! + + lazy var webView = WKWebView() + + override func awakeFromNib() { + super.awakeFromNib() + contentLabel.text = nil + contentView.addSubview(webView) + + webView.scrollView.isScrollEnabled = false + contentLabel.isHidden = true + } + + override func prepareForReuse() { + super.prepareForReuse() + } + + override func layoutSubviews() { + super.layoutSubviews() + webView.frame = contentView.bounds + } + +} + +extension Reactive where Base: ArticleWebContainerCollectionViewCell { + + var didLoadContent: ControlEvent { + let source = self.base.webView.rx.didFinishLoad.map { _ in () } + .delay(.milliseconds(500), scheduler: MainScheduler.instance) + return ControlEvent(events: source) + } + +} diff --git a/DutchNews/Classes/Views/Cells/ArticleWebContainerCollectionViewCell.xib b/DutchNews/Classes/Views/Cells/ArticleWebContainerCollectionViewCell.xib new file mode 100644 index 0000000..0a7e886 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/ArticleWebContainerCollectionViewCell.xib @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DutchNews/Classes/Views/Cells/HalfWidthArticleCollectionViewCell.swift b/DutchNews/Classes/Views/Cells/HalfWidthArticleCollectionViewCell.swift new file mode 100644 index 0000000..5b08637 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/HalfWidthArticleCollectionViewCell.swift @@ -0,0 +1,42 @@ +// +// HalfWidthArticleCollectionViewCell.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import UIKit + +class HalfWidthArticleCollectionViewCell: HeadlineBaseCollectionViewCell { + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var sourceLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 5.0 + + // Initialization code + } + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + imageView.image = #imageLiteral(resourceName: "image-placeHolder") + sourceLabel.text = nil + imageView.cancelCurrentImageLoad() + } + + override func config(viewModel article: ArticleRepresentable) { + + titleLabel.text = article.title + sourceLabel.text = article.source + imageView.setImage(url: article.urlToImage) + super.config(viewModel: article) + + } + +} diff --git a/DutchNews/Classes/Views/Cells/HalfWidthArticleCollectionViewCell.xib b/DutchNews/Classes/Views/Cells/HalfWidthArticleCollectionViewCell.xib new file mode 100644 index 0000000..28e1c6d --- /dev/null +++ b/DutchNews/Classes/Views/Cells/HalfWidthArticleCollectionViewCell.xib @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DutchNews/Classes/Views/Cells/HeadlineBaseCollectionViewCell.swift b/DutchNews/Classes/Views/Cells/HeadlineBaseCollectionViewCell.swift new file mode 100644 index 0000000..31df8b1 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/HeadlineBaseCollectionViewCell.swift @@ -0,0 +1,61 @@ +// +// HeadlineBaseCollectionViewCell.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import MagazineLayout +import RxSwift +import UIKit +import MaterialComponents + +class HeadlineBaseCollectionViewCell: MagazineLayoutCollectionViewCell { + + var disposeBag: DisposeBag! = DisposeBag() + + @IBOutlet var backgroundCard: MDCCardCollectionCell? + + deinit { + disposeBag = nil + backgroundCard = nil + } + + override func prepareForReuse() { + super.awakeFromNib() + disposeBag = nil + disposeBag = DisposeBag() + } + + fileprivate func setupBackgroundView() { + + if backgroundCard == nil { + let view = MDCCardCollectionCell(forAutoLayout: ()) + contentView.insertSubview(view, at: 0) + self.backgroundCard = view + } + + backgroundCard?.isInteractable = false + backgroundCard?.isSelectable = true + backgroundCard?.setShadowElevation(.cardResting, for: .normal) + + backgroundCard?.autoPinEdgesToSuperviewEdges() + } + + override func awakeFromNib() { + super.awakeFromNib() + self.clipsToBounds = false + + setupBackgroundView() + } + + open func config(viewModel: ArticleRepresentable) { + contentView.layoutIfNeeded() + } + + override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) { + contentView.bounds = layoutAttributes.bounds + } +} diff --git a/DutchNews/Classes/Views/Cells/HeadlineSearchTableViewCell.swift b/DutchNews/Classes/Views/Cells/HeadlineSearchTableViewCell.swift new file mode 100644 index 0000000..d6574e6 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/HeadlineSearchTableViewCell.swift @@ -0,0 +1,63 @@ +// +// HeadlineSearchTableViewCell.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/26/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import UIKit +import Foundation +import MaterialComponents +import RxSwift + +class HeadlineSearchTableViewCell: UITableViewCell { + + @IBOutlet weak var backgroundCard: MDCCardCollectionCell! + @IBOutlet weak var articleimageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var descriptionLabel: UILabel! + @IBOutlet weak var sourceLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + + var disposeBag: DisposeBag! = DisposeBag() + + deinit { + disposeBag = nil + backgroundCard = nil + } + + override func awakeFromNib() { + super.awakeFromNib() + articleimageView.clipsToBounds = true + articleimageView.layer.cornerRadius = 5.0 + + backgroundCard?.isInteractable = false + backgroundCard?.isSelectable = true + backgroundCard?.setShadowElevation(.cardResting, for: .normal) + backgroundCard?.autoPinEdgesToSuperviewMargins() + // Initialization code + } + + override func prepareForReuse() { + super.prepareForReuse() + disposeBag = nil + disposeBag = DisposeBag() + titleLabel.text = nil + descriptionLabel.text = nil + sourceLabel.text = nil + dateLabel.text = nil + articleimageView.image = #imageLiteral(resourceName: "image-placeHolder") + articleimageView.cancelCurrentImageLoad() + } + + func config(viewModel article: ArticleRepresentable) { + titleLabel.text = article.title + descriptionLabel.text = article.description + sourceLabel.text = article.source + articleimageView.setImage(url: article.urlToImage) + dateLabel.text = article.publishedAt + + } + +} diff --git a/DutchNews/Classes/Views/Cells/HeadlineSearchTableViewCell.xib b/DutchNews/Classes/Views/Cells/HeadlineSearchTableViewCell.xib new file mode 100644 index 0000000..e9d5052 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/HeadlineSearchTableViewCell.xib @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DutchNews/Classes/Views/Cells/MainArticleCollectionViewCell.swift b/DutchNews/Classes/Views/Cells/MainArticleCollectionViewCell.swift new file mode 100644 index 0000000..a8d1b47 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/MainArticleCollectionViewCell.swift @@ -0,0 +1,58 @@ +// +// MainArticleCollectionViewCell.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/21/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import UIKit +import AVFoundation +import MaterialComponents + +class MainArticleCollectionViewCell: HeadlineBaseCollectionViewCell { + + @IBOutlet weak var gradientView: GradientView! + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var sourceLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + // Initialization code + backgroundCard?.clipsToBounds = true + } + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + imageView.cancelCurrentImageLoad() + imageView.image = nil + sourceLabel.text = nil + } + + override func config(viewModel: ArticleRepresentable) { + titleLabel.text = viewModel.title + sourceLabel.text = viewModel.source + imageView.setImage(url: viewModel.urlToImage) + super.config(viewModel: viewModel) + } + + override func layoutSubviews() { + super.layoutSubviews() + Logger.debugLog("layoutSubviews Aspect ratio 16:9 => \(bounds.size)") + } + + override func preferredLayoutAttributesFitting(_ layoutAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + let attribute = super.preferredLayoutAttributesFitting(layoutAttributes) + + //make sure that aspect ratio applied on size calculation. + let size = AVMakeRect(aspectRatio: CGSize(width: 16, height: 9), + insideRect: CGRect(origin: .zero, size: attribute.size)).size + Logger.debugLog("preferredLayoutAttributes Aspect ratio 16:9 => \(size)") + attribute.size.height = max(size.height, 180) + return attribute + + } + +} diff --git a/DutchNews/Classes/Views/Cells/MainArticleCollectionViewCell.xib b/DutchNews/Classes/Views/Cells/MainArticleCollectionViewCell.xib new file mode 100644 index 0000000..131e824 --- /dev/null +++ b/DutchNews/Classes/Views/Cells/MainArticleCollectionViewCell.xib @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DutchNews/Classes/Views/GradientView/GradientView.swift b/DutchNews/Classes/Views/GradientView/GradientView.swift new file mode 100644 index 0000000..4b1f3e7 --- /dev/null +++ b/DutchNews/Classes/Views/GradientView/GradientView.swift @@ -0,0 +1,132 @@ +// +// GradientView.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/22/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +@IBDesignable +class GradientView: UIView { + + open override class var layerClass: Swift.AnyClass { + return CAGradientLayer.self + } + + var gradientLayer: CAGradientLayer { + // swiftlint:disable:next force_cast + return self.layer as! CAGradientLayer + } + + @IBInspectable + var topColor: UIColor? = .white { + didSet { + self.updateGradiant(colors: (top: topColor, bottom: bottomColor)) + self.setNeedsDisplay() + } + } + + @IBInspectable + var bottomColor: UIColor? = .gray { + + didSet { + self.updateGradiant(colors: (top: topColor, bottom: bottomColor)) + self.setNeedsDisplay() + } + } + + @IBInspectable + var startPoint: CGPoint = .zero { + didSet { + self.gradientLayer.startPoint = startPoint + self.setNeedsDisplay() + self.setNeedsLayout() + } + } + + @IBInspectable + var endPoint: CGPoint = .zero { + didSet { + self.gradientLayer.endPoint = endPoint + self.setNeedsLayout() + self.setNeedsDisplay() + } + } + + @IBInspectable + var startLocation: Float = 0.0 { + didSet { + self.update(locations: [self.startLocation, endLocation]) + } + } + + @IBInspectable + var endLocation: Float = 1.0 { + didSet { + + self.update(locations: [self.startLocation, endLocation]) + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + self.commonInit() + } + + override func prepareForInterfaceBuilder() { +// self.commonInit() + super.prepareForInterfaceBuilder() + + } + + override func layoutSubviews() { + super.layoutSubviews() +// self.gradientLayer.frame = self.bounds + } + + private func commonInit() { + + self.gradientLayer.startPoint = startPoint + self.gradientLayer.endPoint = endPoint + self.update(locations: [self.startLocation, self.endLocation]) + self.updateGradiant(colors: (top: topColor, bottom: bottomColor)) + + } + + private func updateGradiant(colors : (top: UIColor?, bottom: UIColor?)) { + + switch colors { + case (.some(let up), .some(let down)): + self.setGradientColor(colors: [up, down]) + + case (.some(let color), .none), + (.none, .some(let color)): + self.setGradientColor(colors: [color]) + + default: + self.self.setGradientColor(colors: nil) + } + + } + + private func setGradientColor(colors: [UIColor]?) { + self.gradientLayer.colors = colors?.map { $0.cgColor } + self.setNeedsDisplay() + self.setNeedsLayout() + } + + private func update(locations: [Float]) { + self.gradientLayer.locations = locations.map { NSNumber(value: $0) } + self.setNeedsDisplay() + self.setNeedsLayout() + } + +} diff --git a/DutchNews/Classes/Views/GradientView/LinearGradientView.swift b/DutchNews/Classes/Views/GradientView/LinearGradientView.swift new file mode 100644 index 0000000..5886900 --- /dev/null +++ b/DutchNews/Classes/Views/GradientView/LinearGradientView.swift @@ -0,0 +1,159 @@ +// +// LinearGradientView.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/22/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit + +class LinearGradientLayer: CALayer { + + /// <#Description#> + /// + /// - vertical: <#vertical description#> + /// - horizontal: <#horizontal description#> + /// - custom: <#custom description#> + /// - let.custom:: <#let.custom: description#> + enum Direction { + case vertical + case horizontal + case custom(start: CGPoint, end: CGPoint) + + var points: (start: CGPoint, end: CGPoint) { + switch self { + case .vertical: + return (CGPoint(x: 0.5, y: 0.0), CGPoint(x: 0.5, y: 1.0)) + case .horizontal: + return (CGPoint(x: 0.0, y: 0.5), CGPoint(x: 1.0, y: 0.5)) + case let .custom(start, end): + return (start, end) + } + } + } + + var direction: Direction = .vertical + + var colorSpace = CGColorSpaceCreateDeviceRGB() + var colors: [CGColor]? + var locations: [CGFloat]? + + /// <#Description#> + var options = CGGradientDrawingOptions(rawValue: 0) + + // MARK: - Lifecycle + + /// <#Description#> + required override init() { + super.init() + masksToBounds = true + needsDisplayOnBoundsChange = true + } + + /// <#Description#> + /// + /// - Parameter aDecoder: <#aDecoder description#> + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + /// <#Description#> + /// + /// - Parameter layer: <#layer description#> + required override init(layer: Any) { + super.init(layer: layer) + } + + /// <#Description#> + /// + /// - Parameter ctx: <#ctx description#> + override func draw(in ctx: CGContext) { + ctx.saveGState() + + guard let colors = colors, let gradient = CGGradient(colorsSpace: colorSpace, + colors: colors as CFArray, locations: locations) else { return } + + let points = direction.points + ctx.drawLinearGradient( + gradient, + start: transform(points.start), + end: transform(points.end), + options: options + ) + } + + // MARK: - Private + + /// <#Description#> + /// + /// - Parameter point: <#point description#> + /// - Returns: <#return value description#> + private func transform(_ point: CGPoint) -> CGPoint { + return CGPoint(x: bounds.width * point.x, y: bounds.height * point.y) + } +} + +/// <#Description#> +class LinearGradientView: UIView { + + /// <#Description#> + lazy var gradientLayer = layer as? LinearGradientLayer + + /// <#Description#> + override class var layerClass: AnyClass { + return LinearGradientLayer.self + } + + /// <#Description#> + var direction: LinearGradientLayer.Direction = .vertical { + didSet { + updateGradient(with: direction, colors: colors) + } + } + + /// <#Description#> + var colors: [UIColor] = [UIColor.white, UIColor.blue] { + didSet { + guard colors != oldValue else { + return + } + updateGradient(with: direction, colors: colors) + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + /// <#Description#> + /// + /// - Parameters: + /// - direction: <#direction description#> + /// - colors: <#colors description#> + func updateGradient(with direction: LinearGradientLayer.Direction, colors: UIColor...) { + gradientLayer?.direction = direction + gradientLayer?.colors = colors.compactMap { color in + color.cgColor + } + } + + func updateGradient(with direction: LinearGradientLayer.Direction, colors: [UIColor]) { + gradientLayer?.direction = direction + gradientLayer?.colors = colors.compactMap { color in + color.cgColor + } + } + + private func commonInit() { + updateGradient(with: direction, colors: colors) + } + +} diff --git a/DutchNews/Classes/Views/Headers/ArticleDetailHeaderView.swift b/DutchNews/Classes/Views/Headers/ArticleDetailHeaderView.swift new file mode 100644 index 0000000..5dbd77a --- /dev/null +++ b/DutchNews/Classes/Views/Headers/ArticleDetailHeaderView.swift @@ -0,0 +1,35 @@ +// +// ArticleDetailHeaderView.swift +// DutchNews +// +// Created by Farshad Mousalou on 9/23/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import UIKit +import SDWebImage + +class ArticleDetailHeaderView: UIView { + + @IBOutlet weak var backgroundImageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var sourceLabel: UILabel! + @IBOutlet weak var publishDateLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + backgroundImageView.image = #imageLiteral(resourceName: "image-placeHolder") + // Initialization code + } + + func config(content article: ArticleRepresentable) { + + titleLabel.text = article.title + sourceLabel.text = article.source + publishDateLabel.text = article.publishedAt + + backgroundImageView.setImage(url: article.urlToImage, contentMode: .scaleAspectFill) + layoutIfNeeded() + } + +} diff --git a/DutchNews/Classes/Views/Headers/ArticleDetailHeaderView.xib b/DutchNews/Classes/Views/Headers/ArticleDetailHeaderView.xib new file mode 100644 index 0000000..9ded1ad --- /dev/null +++ b/DutchNews/Classes/Views/Headers/ArticleDetailHeaderView.xib @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DutchNews/Info.plist b/DutchNews/Info.plist index 5a63475..75404a2 100644 --- a/DutchNews/Info.plist +++ b/DutchNews/Info.plist @@ -29,8 +29,6 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad diff --git a/DutchNews/Assets.xcassets/AppIcon.appiconset/Contents.json b/DutchNews/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from DutchNews/Assets.xcassets/AppIcon.appiconset/Contents.json rename to DutchNews/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/DutchNews/Assets.xcassets/Contents.json b/DutchNews/Resources/Assets.xcassets/Contents.json similarity index 100% rename from DutchNews/Assets.xcassets/Contents.json rename to DutchNews/Resources/Assets.xcassets/Contents.json diff --git a/DutchNews/Resources/Assets.xcassets/image-placeHolder.imageset/Contents.json b/DutchNews/Resources/Assets.xcassets/image-placeHolder.imageset/Contents.json new file mode 100644 index 0000000..d58d092 --- /dev/null +++ b/DutchNews/Resources/Assets.xcassets/image-placeHolder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "Image.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/DutchNews/Resources/Assets.xcassets/image-placeHolder.imageset/Image.png b/DutchNews/Resources/Assets.xcassets/image-placeHolder.imageset/Image.png new file mode 100644 index 0000000..0ed1f35 Binary files /dev/null and b/DutchNews/Resources/Assets.xcassets/image-placeHolder.imageset/Image.png differ diff --git a/DutchNews/Resources/Localization/en.lproj/Localizable.strings b/DutchNews/Resources/Localization/en.lproj/Localizable.strings new file mode 100644 index 0000000..0ae5d35 --- /dev/null +++ b/DutchNews/Resources/Localization/en.lproj/Localizable.strings @@ -0,0 +1,10 @@ + +//"" = ""; + +"source_title" = "source:"; +"retry" = "Retry"; +"headlines_title" = "Headlines"; +"article_title" = "Article"; +"search_headline_title" = "Search In Headline"; +"search_result_title" = "Result"; +"no_result_found" = "No Result Found! "; diff --git a/DutchNews/Resources/Localization/nl-NL.lproj/Localizable.strings b/DutchNews/Resources/Localization/nl-NL.lproj/Localizable.strings new file mode 100644 index 0000000..d7a83d4 --- /dev/null +++ b/DutchNews/Resources/Localization/nl-NL.lproj/Localizable.strings @@ -0,0 +1,10 @@ + +//"" = ""; + +"source_title" = "bron:"; +"retry" = "Ppnieuw proberen"; +"headlines_title" = "Krantenkoppen"; +"article_title" = "Artikel"; +"search_headline_title" = "Zoek in Headlines"; +"search_result_title" = "Resultaat"; +"no_result_found" = "Geen resultaat gevonden!"; diff --git a/DutchNews/Resources/Storyboards/Base.lproj/LaunchScreen.storyboard b/DutchNews/Resources/Storyboards/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..9ae7699 --- /dev/null +++ b/DutchNews/Resources/Storyboards/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DutchNews/Resources/Storyboards/Base.lproj/Main.storyboard b/DutchNews/Resources/Storyboards/Base.lproj/Main.storyboard new file mode 100644 index 0000000..1e899cd --- /dev/null +++ b/DutchNews/Resources/Storyboards/Base.lproj/Main.storyboard @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DutchNewsTests/ModelsTests/Articles.json b/DutchNewsTests/ModelsTests/Articles.json new file mode 100644 index 0000000..bcdc130 --- /dev/null +++ b/DutchNewsTests/ModelsTests/Articles.json @@ -0,0 +1,262 @@ +[ + { + "source": { + "id": null, + "name": "Telegraaf.nl" + }, + "author": "ANP", + "title": "Trump geeft 'zegen' aan bod Oracle op TikTok - Telegraaf.nl", + "description": "De Amerikaanse president Donald Trump staat achter het bod van Oracle op de Amerikaanse activiteiten van de Chinese filmpjesapp TikTok. „Ik heb de deal mijn zegen gegeven”, zei Trump zaterdag volgens Bloomberg tegen verslaggevers toen hij het Witte Huis verli…", + "url": "https://www.telegraaf.nl/financieel/1442448989/trump-geeft-zegen-aan-bod-oracle-op-tik-tok", + "urlToImage": "https://www.telegraaf.nl/images/1200x630/filters:format(jpeg):quality(80)/cdn-kiosk-api.telegraaf.nl/6409f6dc-fb2c-11ea-acba-02c309bc01c1.jpg", + "publishedAt": "2020-09-20T10:32:00Z", + "content": "Het nieuwe bedrijf, dat volgens minister van Financiën Steven Mnuchin TikTok Global zou gaan heten, is het resultaat van een transactie die vorige maand door Trump werd afgedwongen vanwege bezorgdhei… [+1099 chars]" + }, + { + "source": { + "id": null, + "name": "Nos.nl" + }, + "author": null, + "title": "Privégegevens Wit-Russische politiemensen online in aanloop naar nieuw protest - NOS", + "description": "Hackers dreigen de gegevens van nog veel meer politiemensen te publiceren zolang het hardhandige optreden tegen demonstranten doorgaat.", + "url": "https://nos.nl/l/2349125", + "urlToImage": "https://nos.nl/data/image/2020/09/20/677152/xxl.jpg", + "publishedAt": "2020-09-20T10:15:00Z", + "content": "Hackers hebben privégegevens van ongeveer 1000 Wit-Russische politiemensen gelekt als vergelding voor het arresteren van bijna 400 deelnemers aan de vrouwenmars van gisteren. Ook vandaag gaan Wit-Rus… [+514 chars]" + }, + { + "source": { + "id": null, + "name": "Tweakers.net" + }, + "author": "Mark Hendrikman", + "title": "Sony: pre-orderproces PlayStation 5 kon veel soepeler gaan - Tweakers", + "description": "Sony heeft op Twitter zijn excuses aangeboden voor de moeite die klanten hebben met een pre-order plaatsen van de PlayStation 5. \"Dat kon veel soepeler gaan\", stelt het Japanse bedrijf. Verder geeft het garanties wat betreft voorraden in de rest van het jaar.", + "url": "https://tweakers.net/nieuws/172394/sony-pre-orderproces-playstation-5-kon-veel-soepeler-gaan.html", + "urlToImage": "https://tweakers.net/i/nmiumjECtV-28XAWh4xU09LWx6c=/67x67/filters:strip_exif()/i/2002904894.png?f=fpa", + "publishedAt": "2020-09-20T09:51:39Z", + "content": "Met al die scalpers en bots maak je als normale consument geen schijn van kans.Alsof het normaal is te noemen dat bijv. Bol (en elke andere webwinkel op coolblue na) compleet uitverkocht raakte in lu… [+855 chars]" + }, + { + "source": { + "id": null, + "name": "Rtlboulevard.nl" + }, + "author": null, + "title": "Vriendin Tommie Christiaan in verwachting - RTL Boulevard", + "description": "'Vandaag op ons 2-jarige jubileum willen we graag met iedereen delen dat we papa en mama worden', zo maakt Tommie Christiaan (34) vandaag dolblij bekend dat hij binnenkort vader wordt.", + "url": "https://www.rtlboulevard.nl/entertainment/showbizz/artikel/5184898/vriendin-tommie-christiaan-verwachting-van-een-kleintje", + "urlToImage": "https://www.rtlboulevard.nl/sites/default/files/styles/liggend_hoge_resolutie/public/content/images/2020/09/20/Tommie%20Christiaan.jpg?h=a9edb586&itok=0sJngmbZ", + "publishedAt": "2020-09-20T09:50:39Z", + "content": "De musicalster deelt het leuke nieuwtje op zijn Instagram met daarbij een foto van de buik van zijn vriendin. 'Onze grootste wens komt uit en we zijn zo dankbaar en gelukkig dat we dit mogen meemaken… [+583 chars]" + }, + { + "source": { + "id": null, + "name": "Voetbalprimeur.nl" + }, + "author": null, + "title": "LIVE-discussie: gewijzigd ADO en Groningen jagen op eerste Eredivisie-zege - VoetbalPrimeur.nl", + "description": "ADO Den Haag begint zondag flink gewijzigd aan de eerste thuiswedstrijd van het seizoen in de Eredivisie. Trainer Aleksandr Rankovic geeft onder meer nieuweling Lassana Faye een basisplaats tegen FC Groningen, dat het nog moet stellen zonder Arjen Robben.", + "url": "https://www.voetbalprimeur.nl/nieuws/945567/live-discussie-gewijzigd-ado-en-groningen-jagen-op-eerste-eredivisie-zege.html", + "urlToImage": "https://files.voetbalprimeur.nl/social/2020/09/20/social_07e16002d68daebaf4ab8f699774e501d8928da7.jpg", + "publishedAt": "2020-09-20T09:45:51Z", + "content": "ADO Den Haag begint zondag flink gewijzigd aan de eerste thuiswedstrijd van het seizoen in de Eredivisie. Trainer Aleksandr Rankovic geeft onder meer nieuweling Lassana Faye een basisplaats tegen FC … [+1046 chars]" + }, + { + "source": { + "id": null, + "name": "Racesport.nl" + }, + "author": "https://www.facebook.com/asse.klein", + "title": "Van der Mark wint Superpole Race voor Rea en Baz in Catalunya - Racesport.nl", + "description": "Michael van der Mark rijdt een geweldige wedstrijd en wint de Superpole Race op het Circuit de Barcelona-Catalunya door Jonathan Rea achter zich te houden.", + "url": "https://www.racesport.nl/van-der-mark-wint-superpole-race-voor-rea-en-baz-in-catalunya/", + "urlToImage": "https://www.racesport.nl/wp-content/uploads/2020/09/IMG_6975.jpg", + "publishedAt": "2020-09-20T09:33:52Z", + "content": "Michael van der Mark rijdt een geweldige wedstrijd en wint de Superpole Race op het Circuit de Barcelona-Catalunya door Jonathan Rea achter zich te houden. Ten Kate Racing Yamaha scoort een podium fi… [+5138 chars]" + }, + { + "source": { + "id": null, + "name": "Www.nu.nl" + }, + "author": "NU.nl", + "title": "Bij dodelijke schietpartij Bas van Wijk buitgemaakt horloge was nep-Rolex - NU.nl", + "description": "Het horloge dat vlak voor het doodschieten van de 24-jarige Bas van Wijk in Amsterdam werd buitgemaakt, is een namaak-Rolex. Dat laat het Openbaar Ministerie (OM) zondag aan NU.nl weten na berichtgeving door het Parool. Mogelijk is Van Wijk doodgeschoten omda…", + "url": "https://www.nu.nl/binnenland/6078647/bij-dodelijke-schietpartij-bas-van-wijk-buitgemaakt-horloge-was-nep-rolex.html", + "urlToImage": "https://media.nu.nl/m/34wxnz3adstc_wd1280.jpg/bij-dodelijke-schietpartij-bas-van-wijk-buitgemaakt-horloge-was-nep-rolex.jpg", + "publishedAt": "2020-09-20T09:30:00Z", + "content": "Het horloge dat vlak voor het doodschieten van de 24-jarige Bas van Wijk in Amsterdam werd buitgemaakt, is een namaak-Rolex. Dat laat het Openbaar Ministerie (OM) zondag aan NU.nl weten na berichtgev… [+1614 chars]" + }, + { + "source": { + "id": null, + "name": "Www.nu.nl" + }, + "author": "NU.nl", + "title": "Soundos El Ahmadi rekent al op een tweede seizoen van All Stars & Zonen - NU.nl", + "description": "Het moet volgens comédienne Soundos El Ahmadi heel vreemd lopen wil All Stars & Zonen geen tweede seizoen krijgen. De actrice heeft een hoofdrol in het vervolg van All Stars en gaat er al voor de eerste uitzending van uit dat de nieuwe reeks aanslaat.", + "url": "https://www.nu.nl/film/6077372/soundos-el-ahmadi-rekent-al-op-een-tweede-seizoen-van-all-stars-zonen.html", + "urlToImage": "https://media.nu.nl/m/gp3xy7vat7vl_wd1280.jpg/soundos-el-ahmadi-rekent-al-op-een-tweede-seizoen-van-all-stars-zonen.jpg", + "publishedAt": "2020-09-20T09:17:00Z", + "content": "Het moet volgens comédienne Soundos El Ahmadi heel vreemd lopen wil All Stars & Zonen geen tweede seizoen krijgen. De actrice heeft een hoofdrol in het vervolg van All Stars en gaat er al voor de… [+671 chars]" + }, + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": null, + "title": "'We zaten net naar Netflix te kijken en plotseling zit je zelf in zo'n film'; maar in Kiel-Windeweer is de rust na een schietpartij teruggekeerd - Dagblad van het Noorden", + "description": null, + "url": "https://news.google.com/__i/rss/rd/articles/CBMidWh0dHBzOi8vd3d3LmR2aG4ubmwvZ3JvbmluZ2VuL1dlLXphdGVuLW5ldC1uYWFyLU5ldGZsaXgtdGUta2lqa2VuLWVuLXBsb3RzZWxpbmcteml0LWplLXplbGYtaW4tem9uLWZpbG0tMjYwMzI3OTkuaHRtbNIBAA?oc=5", + "urlToImage": null, + "publishedAt": "2020-09-20T08:52:00Z", + "content": null + }, + { + "source": { + "id": null, + "name": "Dpgmedia.nl" + }, + "author": null, + "title": "Automobilist overlijdt door aanrijding tegen monument in Stadspark Groningen - AD.nl", + "description": null, + "url": "https://myprivacy.dpgmedia.nl/consent/?siteKey=V9f6VUvlHxq9wKIN&callbackUrl=https:%2f%2fwww.ad.nl%2fprivacy-gate%2faccept-tcf2%3fredirectUri%3d%252fgroningen%252fautomobilist-overlijdt-door-aanrijding-tegen-monument-in-stadspark-groningen%257eac916808%252f", + "urlToImage": null, + "publishedAt": "2020-09-20T08:13:17Z", + "content": null + }, + { + "source": { + "id": null, + "name": "Tpo.nl" + }, + "author": "Peil.nl", + "title": "Opiniepeiling Maurice de Hond: Wilders sterkste oppositieleider bij Algemene Beschouwingen - ThePostOnline", + "description": "Slechts 21% wil dat huidig kabinet na de verkiezingen doorgaat", + "url": "https://tpo.nl/2020/09/20/peiling-maurice-de-hond-wilders-na-rutte-het-sterkst/", + "urlToImage": "https://tpo.nl/wp-content/uploads/2020/09/geert-wilders-peil.png", + "publishedAt": "2020-09-20T07:55:45Z", + "content": "De peiling van deze week laat wederom kleine verschuivingen zien, maar ook na de Algemene Beschouwingen zien we geen structurele wijzigingen. De VVD blijft ruim voorop nummer 2 de PVV. De PvdA staat … [+483 chars]" + }, + { + "source": { + "id": null, + "name": "Voetbalzone.nl" + }, + "author": null, + "title": "Frank de Boer lijkt enige kandidaat voor Oranje met tweede gesprek op komst - Voetbalzone.nl", + "description": "", + "url": "https://www.voetbalzone.nl/doc.asp?uid=377347", + "urlToImage": "https://static.voetbalzone.nl/images/photos/ori_1152_648/132351093515435.jpg", + "publishedAt": "2020-09-20T07:53:00Z", + "content": "Er staat maandag een tweede gesprek tussen de KNVB en Frank de Boer op het programma. Pieter Zwart, hoofdredacteur van Voetbal International, vertelt in de liveshow van het weekblad dat er afgelopen … [+2136 chars]" + }, + { + "source": { + "id": null, + "name": "Nos.nl" + }, + "author": null, + "title": "Voor het eerst weer vorst aan de grond gemeten - NOS", + "description": "Bij een KNMI-weerstation in Twente werd vanmorgen om 07.20 uur kort -1,3 graden Celsius gemeten op klomphoogte.", + "url": "https://nos.nl/l/2349109", + "urlToImage": "https://nos.nl/data/image/2020/09/20/677123/xxl.jpg", + "publishedAt": "2020-09-20T07:10:00Z", + "content": "Weerplaza noemt het geen uitzondering dat op dit moment in het jaar de eerste vorst wordt gemeten. In 2012 werd half september zelfs bijna 5 graden vorst aan de grond gemeten.\r\nAfgelopen dinsdag was … [+572 chars]" + }, + { + "source": { + "id": null, + "name": "Dpgmedia.nl" + }, + "author": null, + "title": "Sylvie Meis geeft jawoord in romantisch Toscane: bekijk de foto's hier - AD.nl", + "description": null, + "url": "https://myprivacy.dpgmedia.nl/consent/?siteKey=V9f6VUvlHxq9wKIN&callbackUrl=https:%2f%2fwww.ad.nl%2fprivacy-gate%2faccept-tcf2%3fredirectUri%3d%252fhome%252fsylvie-meis-geeft-jawoord-in-romantisch-toscane-bekijk-de-foto-s-hier%257ea9e9c3d8%252f", + "urlToImage": null, + "publishedAt": "2020-09-20T07:01:35Z", + "content": null + }, + { + "source": { + "id": null, + "name": "Dagelijksestandaard.nl" + }, + "author": "", + "title": "Trump wil komende week nieuwe opvolger Hooggerechtshof kiezen: 'waarschijnlijk een vrouw' - De Dagelijkse Standaard", + "description": "Als het aan de Amerikaanse president Donald Trump ligt dan wordt er zo snel mogelijk een nieuwe opvolger voor het Hooggerechtshof aangewezen. Afgelopen week overleed rechter Ruth Bader Ginsburg, en voor de Trump-regering is dit een gouden kans om een progress…", + "url": "https://www.dagelijksestandaard.nl/2020/09/trump-wil-komende-week-nieuwe-opvolger-hooggerechtshof-kiezen-waarschijnlijk-een-vrouw/", + "urlToImage": "https://cdn-04.dagelijksestandaard.nl/wp-content/uploads/2020/08/vlcsnap-2020-08-04-07h55m32s542.jpg?x90889", + "publishedAt": "2020-09-20T07:00:43Z", + "content": "Als het aan de Amerikaanse president Donald Trump ligt dan wordt er zo snel mogelijk een nieuwe opvolger voor het Hooggerechtshof aangewezen. Afgelopen week overleed rechter Ruth Bader Ginsburg, en v… [+1101 chars]" + }, + { + "source": { + "id": null, + "name": "Dpgmedia.nl" + }, + "author": null, + "title": "In 7 foto's toont Marijn (22) de hel op Lesbos: 'Ik zat regelmatig even te huilen' - De Gelderlander", + "description": null, + "url": "https://myprivacy.dpgmedia.nl/consent/?siteKey=Oom2kBLTny5zJUeO&callbackUrl=https:%2f%2fwww.gelderlander.nl%2fprivacy-gate%2faccept-tcf2%3fredirectUri%3d%252fhome%252fin-7-foto-s-toont-marijn-22-de-hel-op-lesbos-ik-zat-regelmatig-even-te-huilen%257ea9f9da5a%252f", + "urlToImage": null, + "publishedAt": "2020-09-20T06:31:00Z", + "content": null + }, + { + "source": { + "id": null, + "name": "Www.nu.nl" + }, + "author": "NU.nl", + "title": "Pogacar zet Tour op zijn kop in sensationele tijdrit: 'Koers was al voorbij' - NU.nl", + "description": "De strijd om de eindzege leek al beslist voor de tijdrit op de voorlaatste dag van de Tour de France, maar op een sensationele zaterdag in de Vogezen brak Tadej Pogacar de gele droom van Jumbo-Visma en Primoz Roglic in duizend stukjes. Een terugblik op een va…", + "url": "https://www.nu.nl/tour-de-france/6078609/pogacar-zet-tour-op-zijn-kop-in-sensationele-tijdrit-koers-was-al-voorbij.html", + "urlToImage": "https://media.nu.nl/m/s3vx5u9auz1g_wd1280.jpg/pogacar-zet-tour-op-zijn-kop-in-sensationele-tijdrit-koers-was-al-voorbij.jpg", + "publishedAt": "2020-09-20T06:31:00Z", + "content": "De strijd om de eindzege leek al beslist voor de tijdrit op de voorlaatste dag van de Tour de France, maar op een sensationele zaterdag in de Vogezen brak Tadej Pogacar de gele droom van Jumbo-Visma … [+4978 chars]" + }, + { + "source": { + "id": null, + "name": "Nos.nl" + }, + "author": null, + "title": "VS kondigt op eigen houtje VN-sancties af tegen Iran - NOS", + "description": "Het land beroept zich op de Iran-deal uit 2015, maar volgens andere VN-diplomaten is dat niet meer mogelijk omdat Trump eruit is gestapt.", + "url": "https://nos.nl/l/2349102", + "urlToImage": "https://nos.nl/data/image/2020/09/20/677107/xxl.jpg", + "publishedAt": "2020-09-20T06:05:00Z", + "content": "De VS eist dat landen zich weer gaan houden aan VN-sancties die golden voor Iran. \"Dit is een stap op weg naar internationale vrede en veiligheid\", zegt minister Pompeo van Buitenlandse Zaken. De Rus… [+1354 chars]" + }, + { + "source": { + "id": "rtl-nieuws", + "name": "RTL Nieuws" + }, + "author": null, + "title": "Dit gebeurde er in de nacht op Le Mans! - RTL Nieuws", + "description": "De nachtelijke uren in de 24 uur van Le Mans brachten onder andere problemen voor de leidende Toyota, maar helaas ook voor Racing Team Nederland en het team van Job van Uitert. Een terugblik!", + "url": "https://www.rtlnieuws.nl/sport/gp/video/5184877/dit-gebeurde-er-de-nacht-op-le-mans", + "urlToImage": "https://screenshots.rtl.nl/system/thumb/sz=720x404/uuid=d3bff244-8f18-4814-be5c-d9e249db9632", + "publishedAt": "2020-09-20T05:52:44Z", + "content": "De nachtelijke uren in de 24 uur van Le Mans brachten onder andere problemen voor de leidende Toyota, maar helaas ook voor Racing Team Nederland en het team van Job van Uitert. Een terugblik! \r\n20 se… [+17 chars]" + }, + { + "source": { + "id": null, + "name": "Linda.nl" + }, + "author": null, + "title": "Molloot Ivo over aflevering drie: 'Ellie ontpopt zich als de Mol-Mata Hari' - LINDA.", + "description": "Tien kandidaten, van wie één Mol. Journalist Ivo over de derde aflevering van ‘Wie is de Mol?-Renaissance’:", + "url": "https://www.linda.nl/nieuws/fragment-gemist/molloot-ivo-aflevering-drie-ellie-mol-mata-hari/", + "urlToImage": "https://www.linda.nl/lindanl-assets/uploads/2020/09/20111952/ellie-lust-wie-is-de-mol-ivo-1800x1012.jpg", + "publishedAt": "2020-09-20T05:44:04Z", + "content": "Jeroen wist inmiddels dat Ellie zich alleen met hem in de echt wilde verbinden als hij met een flinke bruidsschat zou komen. Hij biechtte dan ook niet veel later op dat hij zichzelf verdacht had will… [+4849 chars]" + } +] diff --git a/DutchNewsTests/ModelsTests/ModelTests.swift b/DutchNewsTests/ModelsTests/ModelTests.swift new file mode 100644 index 0000000..67e7b22 --- /dev/null +++ b/DutchNewsTests/ModelsTests/ModelTests.swift @@ -0,0 +1,93 @@ +// +// ModelTests.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import XCTest + +@testable import DutchNews + +class ModelTests: XCTestCase { + + var decoder: JSONDecoder! + + override func setUp() { + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + decoder = nil + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testDecodingArticles() { + + let data = ModelsDataFactory.createMockArticlesData() + + do { + let objects = try decoder.decode([Article].self, from: data) + XCTAssert(objects.count >= 0 , "data was not able to decode.") + print("Decoded Objc ", objects) + }catch let error { + XCTFail("Error Occurred info: \(error) \(error.localizedDescription)") + } + } + + func testDecodingArticleSource() { + + let data = ModelsDataFactory.createMockSource() + + do { + let object = try decoder.decode(ArticleSource.self, from: data) + XCTAssertNotNil(object, "Data was not able to decode.") + print("Decoded Objc ", object) + }catch let error { + XCTFail("Error Occurred info: \(error) \(error.localizedDescription)") + } + } + + func testHandleDecodingErrorWhenDataCorrupted() { + var corruptedData = ModelsDataFactory.createCorruptedMockArticlesData() + corruptedData = "G ".data(using: .utf8)! + corruptedData + + do { + let object = try decoder.decode([Article].self, from: corruptedData) + XCTAssertNil(object, "The Data was decoded successfully. detail: \(object)") + + }catch let error { + print("expected result -> ",error,error.localizedDescription) + } + } + + func testHandleDecodingErrorWhenArticlesCorrupted() { + + let corruptedData = ModelsDataFactory.createCorruptedMockArticlesData() + + do { + let object = try decoder.decode([Article].self, from: corruptedData) + XCTAssertNil(object, "Responsed was decoded successfully. detail: \(object)") + + }catch let error { + print("expected result -> ",error,error.localizedDescription) + } + } + + func testHandleDecodingErrorWhenArticleSourceCorrupted() { + + let data = ModelsDataFactory.createCorruptedMockSource() + do { + let object = try decoder.decode(ArticleSource.self, from: data) + XCTAssertNil(object, "Responsed was decoded successfully") + print("Decoded Objc ", object) + }catch let error { + print("expected result -> ",error,error.localizedDescription) + + } + } +} diff --git a/DutchNewsTests/ModelsTests/ModelsDataFactory.swift b/DutchNewsTests/ModelsTests/ModelsDataFactory.swift new file mode 100644 index 0000000..f709c5d --- /dev/null +++ b/DutchNewsTests/ModelsTests/ModelsDataFactory.swift @@ -0,0 +1,127 @@ +// +// ModelsDataFactory.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation + +struct ModelsDataFactory { + + //swiftlint:disable all + static func createMockArticlesData() -> Data { + let bundle = Bundle(for: ModelTests.self) + return try! Data(contentsOf: bundle.url(forResource: "Articles", withExtension: "json")!) + } + + static func createCorruptedMockArticlesData() -> Data { + return """ + [{ + "source": { + "id": null, + "name": "Rtlboulevard.nl" + }, + "author": null, + "title": "Vriendin Tommie Christiaan in verwachting - RTL Boulevard", + "description": "'Vandaag op ons 2-jarige jubileum willen we graag met iedereen delen dat we papa en mama worden', zo maakt Tommie Christiaan (34) vandaag dolblij bekend dat hij binnenkort vader wordt.", + "url": "https://www.rtlboulevشسیضصثضصثضصثard.nl/entertainment/showbizz/artikel/5184898/vriendin-tommie-christiaan-verwachting-van-een-kleintje", + "urlToImage": "https://www.rtlboulevard.nl/sites/default/files/styles/liggend_hoge_resolutie/public/content/images/2020/09/20/Tommie%20Christiaan.jpg?h=a9edb586&itok=0sJngmbZ", + "publishedAt": "2020-09-20T09:50:39Z", + "content": "De musicalster deelt het leuke nieuwtje op zijn Instagram met daarbij een foto van de buik van zijn vriendin. 'Onze grootste wens komt uit en we zijn zo dankbaar en gelukkig dat we dit mogen meemaken… [+583 chars]" + }, + { + "source": { + "id": null, + "name": "Voetbalprimeur.nl" + }, + "author": null, + "title": "LIVE-discussie: gewijzigd ADO en Groningen jagen op eerste Eredivisie-zege - VoetbalPrimeur.nl", + "description": "ADO Den Haag begint zondag flink gewijzigd aan de eerste thuiswedstrijd van het seizoen in de Eredivisie. Trainer Aleksandr Rankovic geeft onder meer nieuweling Lassana Faye een basisplaats tegen FC Groningen, dat het nog moet stellen zonder Arjen Robben.", + "url": "https://www.voetbalprimeur.nl/nieuws/945567/live-discussie-gewijzigd-ado-en-groningen-jagen-op-eerste-eredivisie-zege.html", + "urlToImage": "https://files.voetbalprimeur.nl/social/2020/09/20/social_07e16002d68daebaf4ab8f699774e501d8928da7.jpg", + "publishedAt": "2020-09-20T09:45:51Z", + "content": "ADO Den Haag begint zondag flink gewijzigd aan de eerste thuiswedstrijd van het seizoen in de Eredivisie. Trainer Aleksandr Rankovic geeft onder meer nieuweling Lassana Faye een basisplaats tegen FC … [+1046 chars]" + }, + { + "source": { + "id": null, + "name": "Racesport.nl" + }, + "author": "https://www.facebook.com/asse.klein", + "title": "Van der Mark wint Superpole Race voor Rea en Baz in Catalunya - Racesport.nl", + "description": "Michael van der Mark rijdt een geweldige wedstrijd en wint de Superpole Race op het Circuit de Barcelona-Catalunya door Jonathan Rea achter zich te houden.", + "url": "https://www.racesport.nl/van-der-mark-wint-superpole-race-voor-rea-en-baz-in-catalunya/", + "urlToImage": "https://www.racesport.nl/wp-content/uploads/2020/09/IMG_6975.jpg", + "publishedAt": "2020-09-20T09:33:52Z", + }, + { + "source": { + "id": null, + "name": "Www.nu.nl" + }, + "author": "NU.nl", + "description": "Het horloge dat vlak voor het doodschieten van de 24-jarige Bas van Wijk in Amsterdam werd buitgemaakt, is een namaak-Rolex. Dat laat het Openbaar Ministerie (OM) zondag aan NU.nl weten na berichtgeving door het Parool. Mogelijk is Van Wijk doodgeschoten omda…", + "url": "udp://www.nu.nl/binnenland/6078647/bij-dodelijke-schietpartij-bas-van-wijk-buitgemaakt-horloge-was-nep-rolex.html", + "urlToImage": "https://media.nu.nl/m/34wxnz3adstc_wd1280.jpg/bij-dodelijke-schietpartij-bas-van-wijk-buitgemaakt-horloge-was-nep-rolex.jpg", + "publishedAt": "2020-09-20T09:30:00Z", + "content": "Het horloge dat vlak voor het doodschieten van de 24-jarige Bas van Wijk in Amsterdam werd buitgemaakt, is een namaak-Rolex. Dat laat het Openbaar Ministerie (OM) zondag aan NU.nl weten na berichtgev… [+1614 chars]" + } + ] + """.data(using: .utf8)! + } + + static func createMockArticlesDataWithCorruptedSources() -> Data { + return """ + [{ + "source": { + "id": null + }, + "author": "ANP", + "title": "Trump geeft 'zegen' aan bod Oracle op TikTok - Telegraaf.nl", + "description": "De Amerikaanse president Donald Trump staat achter het bod van Oracle op de Amerikaanse activiteiten van de Chinese filmpjesapp TikTok. „Ik heb de deal mijn zegen gegeven”, zei Trump zaterdag volgens Bloomberg tegen verslaggevers toen hij het Witte Huis verli…", + "url": "https://www.telegraaf.nl/financieel/1442448989/trump-geeft-zegen-aan-bod-oracle-op-tik-tok", + "urlToImage": "https://www.telegraaf.nl/images/1200x630/filters:format(jpeg):quality(80)/cdn-kiosk-api.telegraaf.nl/6409f6dc-fb2c-11ea-acba-02c309bc01c1.jpg", + "publishedAt": "2020-09-20T10:32:00Z", + "content": "Het nieuwe bedrijf, dat volgens minister van Financiën Steven Mnuchin TikTok Global zou gaan heten, is het resultaat van een transactie die vorige maand door Trump werd afgedwongen vanwege bezorgdhei… [+1099 chars]" + }, + { + "source": { + "id": null, + "name": "Nos.nl" + }, + "author": null, + "title": "Privégegevens Wit-Russische politiemensen online in aanloop naar nieuw protest - NOS", + "description": "Hackers dreigen de gegevens van nog veel meer politiemensen te publiceren zolang het hardhandige optreden tegen demonstranten doorgaat.", + "url": "https://nos.nl/l/2349125", + "urlToImage": "https://nos.nl/data/image/2020/09/20/677152/xxl.jpg", + "publishedAt": "2020-09-20T10:15:00Z", + "content": "Hackers hebben privégegevens van ongeveer 1000 Wit-Russische politiemensen gelekt als vergelding voor het arresteren van bijna 400 deelnemers aan de vrouwenmars van gisteren. Ook vandaag gaan Wit-Rus… [+514 chars]" + }, + { + "source": { + "id": null, + }, + "author": "Mark Hendrikman", + "title": "Sony: pre-orderproces PlayStation 5 kon veel soepeler gaan - Tweakers", + "description": "Sony heeft op Twitter zijn excuses aangeboden voor de moeite die klanten hebben met een pre-order plaatsen van de PlayStation 5. \"Dat kon veel soepeler gaan\", stelt het Japanse bedrijf. Verder geeft het garanties wat betreft voorraden in de rest van het jaar.", + "url": "https://tweakers.net/nieuws/172394/sony-pre-orderproces-playstation-5-kon-veel-soepeler-gaan.html", + "urlToImage": "https://tweakers.net/i/nmiumjECtV-28XAWh4xU09LWx6c=/67x67/filters:strip_exif()/i/2002904894.png?f=fpa", + "publishedAt": "2020-09-20T09:51:39Z", + "content": "Met al die scalpers en bots maak je als normale consument geen schijn van kans.Alsof het normaal is te noemen dat bijv. Bol (en elke andere webwinkel op coolblue na) compleet uitverkocht raakte in lu… [+855 chars]" + }] + """ + .data(using: .utf8)! + } + + static func createMockSource() -> Data { + return "{ \"id\": null, \"name\": \"Telegraph\" }".data(using: .utf8)! + } + + static func createCorruptedMockSource() -> Data { + return "{ \"id\": null }".data(using: .utf8)! + } + //swiftlint: disable enable + +} diff --git a/DutchNewsTests/NetworkTests/APIAuthenticatorTests.swift b/DutchNewsTests/NetworkTests/APIAuthenticatorTests.swift new file mode 100644 index 0000000..63a7459 --- /dev/null +++ b/DutchNewsTests/NetworkTests/APIAuthenticatorTests.swift @@ -0,0 +1,115 @@ +// +// APIAuthenticatorTests.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/19/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import XCTest +import Foundation +import UIKit +import Mocker +import Alamofire +@testable import DutchNews + +class APIAuthenticatorTests: XCTestCase { + + var networkService: NetworkServiceInterceptable! + + override func setUp() { + + networkService = APIClientService(baseURL: URL(string: "https://newsapi.org/")!) + + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + networkService = nil + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testUnauthenticatedRequest() { + + let expectations = self.expectation(description: "UnaunthenticatedRequest") + + _ = networkService.executeRequest(endpoint: "v2/top-headlines", + parameters: ["country": "nl"], + method: .get, + headers: [:], + validator: nil, + completion: { (result: Result) in + switch result { + case .success(let value): + print(value) + XCTFail("Server must return unauthorized error with status code 401") + case .failure(let error): + print("Response Error => ",error, "localized Info: ",error.localizedDescription) + } + + expectations.fulfill() + }) + + self.waitForExpectations(timeout: 30.0) { (error) in + XCTAssertNil(error, "error occured \(error!)") + } + } + + func testEmptyAPIKeyAuthenticationRequest() { + + let expectations = self.expectation(description: "EmptyAPIKeyAuthentication") + let authenticator: RequestInterceptor = APIAuthenticator(token: "") + + networkService.addingRequest(interceptor: authenticator) + + _ = networkService.executeRequest(endpoint: "v2/top-headlines", + parameters: ["country": "nl"], + method: .get, + headers: [:], + validator: nil, + completion: { (result: Result) in + switch result { + + case .success(let value): + print(value) + XCTFail("Server must return unauthorized error with status code 401") + case .failure(let error): + print("Response Error => ",error, "localized Info: ",error.localizedDescription) + } + + expectations.fulfill() + }) + + self.waitForExpectations(timeout: 30.0) { (error) in + XCTAssertNil(error, "error occured \(error!)") + } + } + + func testAuthenticationSuccessfullRequest() { + + let expectations = self.expectation(description: "AuthenticationSuccessfull") + let authenticator: RequestInterceptor = APIAuthenticator(token: AppConfig.APIKey) + + networkService.addingRequest(interceptor: authenticator) + + _ = networkService.executeRequest(endpoint: "v2/top-headlines", + parameters: ["country": "nl"], + method: .get, + headers: [:], + validator: nil, + completion: { (result: Result) in + switch result { + case .success(let value): + print(value) + case .failure(let error): + XCTFail("Response Error => \(error), localized Info: \(error.localizedDescription)") + } + + expectations.fulfill() + }) + + self.waitForExpectations(timeout: 30.0) { (error) in + XCTAssertNil(error, "error occured \(error!)") + } + } +} diff --git a/DutchNewsTests/NetworkTests/APIClientServiceTests.swift b/DutchNewsTests/NetworkTests/APIClientServiceTests.swift new file mode 100644 index 0000000..16be3a5 --- /dev/null +++ b/DutchNewsTests/NetworkTests/APIClientServiceTests.swift @@ -0,0 +1,195 @@ +// +// APIClientServiceTests.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/19/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import XCTest +import Foundation +import UIKit +import Mocker +import Alamofire +@testable import DutchNews + +class APIClientServiceTests: XCTestCase { + + typealias DataDecoder = Alamofire.DataDecoder + + var networkService: NetworkService! + + override func setUp() { + let configuration = URLSessionConfiguration.af.default + configuration.protocolClasses = [MockingURLProtocol.self] + let sessionManager = Alamofire.Session(configuration: configuration) + + networkService = APIClientService(baseURL: URL(string: "https://domain.com/")!, + session: sessionManager, decoder: JSONDecoder()) + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + networkService = nil + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testSimpleRequestResponse() { + let expectedData = NetworkMockingDataFactory.createSimpleJSONData() + + let expectations = self.expectation(description: "SimpleRequestResponse") + + do { + let mock = try NetworkMockBuilder(URL: "https://domain.com/api/user") + .set(method: .get) + .set(statusCode: 200) + .set(data: [.get: expectedData]) + .set(contentType: .json) + .build() + + Mocker.register(mock) + + _ = networkService.executeRequest(endpoint: "api/user", + parameters: [:], + method: .get, + headers: [:], + validator: nil, + completion: { (result: Result) in + switch result { + case .success(let value): + print(value) + case .failure(let error): + XCTFail(error.localizedDescription) + } + + expectations.fulfill() + }) + + } catch let error { + XCTFail(error.localizedDescription) + } + + self.waitForExpectations(timeout: 10.0) { (error) in + XCTAssertNil(error, "error occured \(error!)") + } + } + + func testSimpleResponseContentTypeErrorHandling() { + + let expectedData = NetworkMockingDataFactory.createSimpleJSONData() + let expectations = self.expectation(description: "SimpleResponseContentType") + + do { + let mock = try NetworkMockBuilder(URL: "https://domain.com/api/user") + .set(method: .get) + .set(contentType: .html) + .set(statusCode: 200) + .set(data: [.get: expectedData]) + .build() + + Mocker.register(mock) + + _ = networkService.executeRequest(endpoint: "/api/user", + parameters: [:], + method: .get, + headers: ["Accept": "application/json"], + validator: nil, + completion: { ( result: Result) in + print("result => ", result) + switch result { + case .success: + XCTFail("SimpleResponse should not have a value response") + case .failure(let error): + print("Error Occured ",error.localizedDescription) + } + expectations.fulfill() + }) + + } catch let error { + XCTFail(error.localizedDescription) + } + + self.waitForExpectations(timeout: 10.0) { (error) in + XCTAssertNil(error, "error occured \(error!)") + } + } + + func testSimpleResponseStatusCodeError() { + + let expectations = self.expectation(description: "SimpleResponseStatusCode") + + do { + let mock = try NetworkMockBuilder(URL: "https://domain.com/api/user") + .set(method: .get) + .set(statusCode: 400) + .set(data: [.get: "".data(using: .utf8)!]) + .set(contentType: .json) + .build() + + Mocker.register(mock) + + _ = networkService.executeRequest(endpoint: "api/user", + parameters: [:], + method: .get, + headers: [:], + validator: nil, + completion: { (result: Result) in + switch result { + case .success: + XCTFail("SimpleResponse should not have a value response") + case .failure(let error): + print("Error Occured ",error.localizedDescription) + } + + expectations.fulfill() + }) + + } catch let error { + XCTFail(error.localizedDescription) + } + + self.waitForExpectations(timeout: 10.0) { (error) in + XCTAssertNil(error, "error occured \(error!)") + } + } + + func testRequestWithEmptyResponse() { + + let expectations = self.expectation(description: "EmptyRequestResponse") + + do { + let mock = try NetworkMockBuilder(URL: "https://domain.com/api/user") + .set(method: .get) + .set(statusCode: 204) + .set(data: [.head: "".data(using: .utf8)!]) + .set(contentType: .json) + .build() + + Mocker.register(mock) + + _ = networkService.executeRequest(endpoint: "api/user", + parameters: [:], + method: .head, + headers: [:], + validator: nil, + completion: { (result: Result) in + switch result { + case .success: + XCTFail("The response should had a value.") + case .failure(let error): + print("error descripition :",error,error.localizedDescription) + } + + expectations.fulfill() + }) + + } catch let error { + XCTFail(error.localizedDescription) + } + + self.waitForExpectations(timeout: 10.0) { (error) in + XCTAssertNil(error, "error occured \(error!)") + } + } + +} diff --git a/DutchNewsTests/NetworkTests/APIServerResponseTests.swift b/DutchNewsTests/NetworkTests/APIServerResponseTests.swift new file mode 100644 index 0000000..cbfb948 --- /dev/null +++ b/DutchNewsTests/NetworkTests/APIServerResponseTests.swift @@ -0,0 +1,96 @@ +// +// APIServerResponseTests.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import XCTest +import Foundation + +@testable import DutchNews + +class APIServerResponseTests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testSuccessAPIResponse() { + let data = """ + { + "status": "ok", + "totalResults": 34, + "articles": [] + } + """.data(using: .utf8)! + do { + let response = try JSONDecoder().decode(APIServerResponse<[String]>.self, from: data) + XCTAssert(response.status == .success , "APIServerResponse data was not able to decode.") + XCTAssertNotNil(response.data, "APIServerResponse data was not able to decode.") + + print("Decoded Objc ", response) + }catch let error { + XCTFail("Error Occurred info: \(error) \(error.localizedDescription)") + } + } + + func testFailureAPIResponse() { + let data = """ + { + "status": "error", + "code": "apiKeyInvalid", + "message": "Your API key is invalid or incorrect. Check your key, or go to https://newsapi.org to create a free API key." + } + """.data(using: .utf8)! + + do { + let response = try JSONDecoder().decode(APIServerResponse<[String]>.self, from: data) + XCTAssertNil(response, "Responsed was decoded successfully. detail: \(response)") + }catch let error { + print("expected result -> ",error,error.localizedDescription) + } + + } + + func testSuccessDecoding() { + let data = """ + { + "status": "ok", + "totalResults": 34, + "articles": [] + } + """.data(using: .utf8)! + do { + let response = try JSONDecoder().decode(APIServerResponse<[String]>.self, from: data) + XCTAssert(response.status == .success , "APIServerResponse data was not able to decode.") + XCTAssertNotNil(response, "APIServerResponse data was not able to decode.") + + print("Decoded Objc ", response) + }catch let error { + XCTFail("Error Occurred info: \(error) \(error.localizedDescription)") + } + } + + func testFailureDecoding() { + let data = """ + { + "status": "fail", + "message": "Your API key is invalid or incorrect. Check your key, or go to https://newsapi.org to create a free API key." + } + """.data(using: .utf8)! + + do { + let response = try JSONDecoder().decode(APIServerResponse<[String]>.self, from: data) + XCTAssertNil(response, "Responsed was decoded successfully. detail: \(response)") + }catch let error { + print("expected result -> ",error,error.localizedDescription) + } + } + +} diff --git a/DutchNewsTests/NetworkTests/Helper/NetworkMocking.swift b/DutchNewsTests/NetworkTests/Helper/NetworkMocking.swift new file mode 100644 index 0000000..a86a62a --- /dev/null +++ b/DutchNewsTests/NetworkTests/Helper/NetworkMocking.swift @@ -0,0 +1,61 @@ +// +// NetworkMocking.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/19/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit +import Mocker +import Alamofire +@testable import DutchNews + +class NetworkMockBuilder { + + private var url: URLConvertible + private var method: Mock.HTTPMethod = .get + private var contentType: Mock.ContentType = .html + private var statusCode: Int = 200 + private var data: [Mock.HTTPMethod: Data] = [:] + private var headers: [String: String] = [:] + + init(URL: URLConvertible) { + self.url = URL + } + + func set(method: Mock.HTTPMethod) -> NetworkMockBuilder { + self.method = method + return self as NetworkMockBuilder + } + + func set(contentType: Mock.ContentType) -> NetworkMockBuilder { + self.contentType = contentType + return self + } + + func set(statusCode: Int) -> NetworkMockBuilder { + self.statusCode = statusCode + return self + } + + func set(headers: [String: String]) -> NetworkMockBuilder { + self.headers = headers + return self + } + + func set(data: [Mock.HTTPMethod: Data]) -> NetworkMockBuilder { + self.data = data + return self + } + + func build() throws -> Mock { + let mock = Mock(url: try url.asURL(), + contentType: contentType, + statusCode: statusCode, data: data, additionalHeaders: headers) + + return mock + } + +} diff --git a/DutchNewsTests/NetworkTests/Helper/NetworkMockingDataFactory.swift b/DutchNewsTests/NetworkTests/Helper/NetworkMockingDataFactory.swift new file mode 100644 index 0000000..10c62d8 --- /dev/null +++ b/DutchNewsTests/NetworkTests/Helper/NetworkMockingDataFactory.swift @@ -0,0 +1,47 @@ +// +// NetworkMockingDataFactory.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/19/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import UIKit +import Mocker +import Alamofire +@testable import DutchNews + +// swiftlint:disable all +struct NetworkMockingDataFactory { + + static func createSimpleJSONData() -> Data { + let person = StubPerson(name: "John", age: 20, email: "john@gmail.com") + let data = try! JSONEncoder().encode(person) + return data + } + + static func createSimpleArrayJSONData() -> Data { + let persons = [StubPerson(name: "John", age: 20, email: "john@gmail.com"), StubPerson(name: "Smith", age: 40, email: nil)] + let data = try! JSONEncoder().encode(persons) + return data + } + + + static func createRealJSONData() -> Data { + fatalError("") + } + + static func createImageData() -> Data { + fatalError("") + } + +} + +struct StubPerson: Codable { + + let name: String + let age: Int + let email: String? +} +// swiftlint:enable all diff --git a/DutchNewsTests/Presistence/CodableDataManagerTests.swift b/DutchNewsTests/Presistence/CodableDataManagerTests.swift new file mode 100644 index 0000000..6dadeea --- /dev/null +++ b/DutchNewsTests/Presistence/CodableDataManagerTests.swift @@ -0,0 +1,134 @@ +// +// CodableDataManagerTests.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import XCTest +import Foundation + +@testable import DutchNews + +class CodableDataManagerTests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testSaveObject() { + do { + + let storage: Storage = CodableDataManager(fileProvider: .custom(url: try! URL.documentDirectoryURL())) + + try storage.save(object: Article.htmlArticle()) + try storage.save(object: Article.htmlArticle()) + + }catch { + XCTFail(error.localizedDescription) + } + } + + func testFetchAllElements() { + + do { + let expe = self.expectation(description: "testEEE") + expe.expectedFulfillmentCount = 1 + + let storage: Storage = CodableDataManager(fileProvider: .custom(url: try! URL.documentDirectoryURL())) + + try storage.save(object: Article.htmlArticle()) + try storage.save(object: Article.htmlArticle()) + try storage.save(object: Article.htmlArticle()) + try storage.save(object: Article.htmlArticle()) + + try storage.fetch(type: Article.self, predicate: nil, sort: nil) { (result) in + print(result) + expe.fulfill() + } + + self.waitForExpectations(timeout: 30.0) { (error) in + if let error = error { + XCTFail(error.localizedDescription) + } + } + }catch { + XCTFail(error.localizedDescription) + } + + } + + func testFetchElementsWithPrediction() { + + do { + let expe = self.expectation(description: "testEEE") + expe.expectedFulfillmentCount = 2 + + let storage: Storage = CodableDataManager(fileProvider: .custom(url: try! URL.documentDirectoryURL())) + + let people = [StubPerson(name: "John", age: 21, email: "m@mail.com"), + StubPerson(name: "Sam", age: 35, email: "sam@gmail.com"), + StubPerson(name: "Goorge", age: 40, email: "goorge@gmail.com")] + + for person in people { + try? storage.save(object: person) + } + + let commitPredicate = NSPredicate { (obj, _) -> Bool in + return ((obj as? StubPerson)?.name ?? "") == "John" + } + + try storage.fetch(type: StubPerson.self, predicate: commitPredicate, sort: nil) { (result) in + XCTAssertTrue(result.count > 0, "Not Found John") + expe.fulfill() + } + + let predict2 = NSPredicate { (obj, _) -> Bool in + return ((obj as? StubPerson)?.email ?? "").contains(".com") + } + + try storage.fetch(type: StubPerson.self, predicate: predict2, sort: nil) { (result) in + XCTAssertTrue(result.count > 0, "no names found in list which contains 'o'") + expe.fulfill() + } + + self.waitForExpectations(timeout: 30.0) { (error) in + if let error = error { + XCTFail(error.localizedDescription) + } + } + }catch { + XCTFail(error.localizedDescription) + } + + } + + func testPerformanceExample() { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} + +extension StubPerson: Storable { + + func primaryKeyValue() -> String { + return self.name + } + +} + +extension StubPerson: Equatable { + + static func ==(lhs: StubPerson, rhs: StubPerson) -> Bool { + return lhs.name == rhs.name && lhs.age == rhs.age && lhs.email == rhs.email + } + +} diff --git a/DutchNewsTests/Repositories/HeadLines/HeadlineFailureResponse.json b/DutchNewsTests/Repositories/HeadLines/HeadlineFailureResponse.json new file mode 100644 index 0000000..f601d0f --- /dev/null +++ b/DutchNewsTests/Repositories/HeadLines/HeadlineFailureResponse.json @@ -0,0 +1,5 @@ +{ + "status": "error", + "code": "parametersMissing", + "message": "Required parameters are missing. Please set any of the following parameters and try again: sources, q, language, country, category." +} diff --git a/DutchNewsTests/Repositories/HeadLines/HeadlineSuccessResponse.json b/DutchNewsTests/Repositories/HeadLines/HeadlineSuccessResponse.json new file mode 100644 index 0000000..f10af8f --- /dev/null +++ b/DutchNewsTests/Repositories/HeadLines/HeadlineSuccessResponse.json @@ -0,0 +1,266 @@ +{ + "status": "ok", + "totalResults": 34, + "articles": [ + { + "source": { + "id": null, + "name": "Telegraaf.nl" + }, + "author": "ANP", + "title": "Trump geeft 'zegen' aan bod Oracle op TikTok - Telegraaf.nl", + "description": "De Amerikaanse president Donald Trump staat achter het bod van Oracle op de Amerikaanse activiteiten van de Chinese filmpjesapp TikTok. „Ik heb de deal mijn zegen gegeven”, zei Trump zaterdag volgens Bloomberg tegen verslaggevers toen hij het Witte Huis verli…", + "url": "https://www.telegraaf.nl/financieel/1442448989/trump-geeft-zegen-aan-bod-oracle-op-tik-tok", + "urlToImage": "https://www.telegraaf.nl/images/1200x630/filters:format(jpeg):quality(80)/cdn-kiosk-api.telegraaf.nl/6409f6dc-fb2c-11ea-acba-02c309bc01c1.jpg", + "publishedAt": "2020-09-20T10:32:00Z", + "content": "Het nieuwe bedrijf, dat volgens minister van Financiën Steven Mnuchin TikTok Global zou gaan heten, is het resultaat van een transactie die vorige maand door Trump werd afgedwongen vanwege bezorgdhei… [+1099 chars]" + }, + { + "source": { + "id": null, + "name": "Nos.nl" + }, + "author": null, + "title": "Privégegevens Wit-Russische politiemensen online in aanloop naar nieuw protest - NOS", + "description": "Hackers dreigen de gegevens van nog veel meer politiemensen te publiceren zolang het hardhandige optreden tegen demonstranten doorgaat.", + "url": "https://nos.nl/l/2349125", + "urlToImage": "https://nos.nl/data/image/2020/09/20/677152/xxl.jpg", + "publishedAt": "2020-09-20T10:15:00Z", + "content": "Hackers hebben privégegevens van ongeveer 1000 Wit-Russische politiemensen gelekt als vergelding voor het arresteren van bijna 400 deelnemers aan de vrouwenmars van gisteren. Ook vandaag gaan Wit-Rus… [+514 chars]" + }, + { + "source": { + "id": null, + "name": "Tweakers.net" + }, + "author": "Mark Hendrikman", + "title": "Sony: pre-orderproces PlayStation 5 kon veel soepeler gaan - Tweakers", + "description": "Sony heeft op Twitter zijn excuses aangeboden voor de moeite die klanten hebben met een pre-order plaatsen van de PlayStation 5. \"Dat kon veel soepeler gaan\", stelt het Japanse bedrijf. Verder geeft het garanties wat betreft voorraden in de rest van het jaar.", + "url": "https://tweakers.net/nieuws/172394/sony-pre-orderproces-playstation-5-kon-veel-soepeler-gaan.html", + "urlToImage": "https://tweakers.net/i/nmiumjECtV-28XAWh4xU09LWx6c=/67x67/filters:strip_exif()/i/2002904894.png?f=fpa", + "publishedAt": "2020-09-20T09:51:39Z", + "content": "Met al die scalpers en bots maak je als normale consument geen schijn van kans.Alsof het normaal is te noemen dat bijv. Bol (en elke andere webwinkel op coolblue na) compleet uitverkocht raakte in lu… [+855 chars]" + }, + { + "source": { + "id": null, + "name": "Rtlboulevard.nl" + }, + "author": null, + "title": "Vriendin Tommie Christiaan in verwachting - RTL Boulevard", + "description": "'Vandaag op ons 2-jarige jubileum willen we graag met iedereen delen dat we papa en mama worden', zo maakt Tommie Christiaan (34) vandaag dolblij bekend dat hij binnenkort vader wordt.", + "url": "https://www.rtlboulevard.nl/entertainment/showbizz/artikel/5184898/vriendin-tommie-christiaan-verwachting-van-een-kleintje", + "urlToImage": "https://www.rtlboulevard.nl/sites/default/files/styles/liggend_hoge_resolutie/public/content/images/2020/09/20/Tommie%20Christiaan.jpg?h=a9edb586&itok=0sJngmbZ", + "publishedAt": "2020-09-20T09:50:39Z", + "content": "De musicalster deelt het leuke nieuwtje op zijn Instagram met daarbij een foto van de buik van zijn vriendin. 'Onze grootste wens komt uit en we zijn zo dankbaar en gelukkig dat we dit mogen meemaken… [+583 chars]" + }, + { + "source": { + "id": null, + "name": "Voetbalprimeur.nl" + }, + "author": null, + "title": "LIVE-discussie: gewijzigd ADO en Groningen jagen op eerste Eredivisie-zege - VoetbalPrimeur.nl", + "description": "ADO Den Haag begint zondag flink gewijzigd aan de eerste thuiswedstrijd van het seizoen in de Eredivisie. Trainer Aleksandr Rankovic geeft onder meer nieuweling Lassana Faye een basisplaats tegen FC Groningen, dat het nog moet stellen zonder Arjen Robben.", + "url": "https://www.voetbalprimeur.nl/nieuws/945567/live-discussie-gewijzigd-ado-en-groningen-jagen-op-eerste-eredivisie-zege.html", + "urlToImage": "https://files.voetbalprimeur.nl/social/2020/09/20/social_07e16002d68daebaf4ab8f699774e501d8928da7.jpg", + "publishedAt": "2020-09-20T09:45:51Z", + "content": "ADO Den Haag begint zondag flink gewijzigd aan de eerste thuiswedstrijd van het seizoen in de Eredivisie. Trainer Aleksandr Rankovic geeft onder meer nieuweling Lassana Faye een basisplaats tegen FC … [+1046 chars]" + }, + { + "source": { + "id": null, + "name": "Racesport.nl" + }, + "author": "https://www.facebook.com/asse.klein", + "title": "Van der Mark wint Superpole Race voor Rea en Baz in Catalunya - Racesport.nl", + "description": "Michael van der Mark rijdt een geweldige wedstrijd en wint de Superpole Race op het Circuit de Barcelona-Catalunya door Jonathan Rea achter zich te houden.", + "url": "https://www.racesport.nl/van-der-mark-wint-superpole-race-voor-rea-en-baz-in-catalunya/", + "urlToImage": "https://www.racesport.nl/wp-content/uploads/2020/09/IMG_6975.jpg", + "publishedAt": "2020-09-20T09:33:52Z", + "content": "Michael van der Mark rijdt een geweldige wedstrijd en wint de Superpole Race op het Circuit de Barcelona-Catalunya door Jonathan Rea achter zich te houden. Ten Kate Racing Yamaha scoort een podium fi… [+5138 chars]" + }, + { + "source": { + "id": null, + "name": "Www.nu.nl" + }, + "author": "NU.nl", + "title": "Bij dodelijke schietpartij Bas van Wijk buitgemaakt horloge was nep-Rolex - NU.nl", + "description": "Het horloge dat vlak voor het doodschieten van de 24-jarige Bas van Wijk in Amsterdam werd buitgemaakt, is een namaak-Rolex. Dat laat het Openbaar Ministerie (OM) zondag aan NU.nl weten na berichtgeving door het Parool. Mogelijk is Van Wijk doodgeschoten omda…", + "url": "https://www.nu.nl/binnenland/6078647/bij-dodelijke-schietpartij-bas-van-wijk-buitgemaakt-horloge-was-nep-rolex.html", + "urlToImage": "https://media.nu.nl/m/34wxnz3adstc_wd1280.jpg/bij-dodelijke-schietpartij-bas-van-wijk-buitgemaakt-horloge-was-nep-rolex.jpg", + "publishedAt": "2020-09-20T09:30:00Z", + "content": "Het horloge dat vlak voor het doodschieten van de 24-jarige Bas van Wijk in Amsterdam werd buitgemaakt, is een namaak-Rolex. Dat laat het Openbaar Ministerie (OM) zondag aan NU.nl weten na berichtgev… [+1614 chars]" + }, + { + "source": { + "id": null, + "name": "Www.nu.nl" + }, + "author": "NU.nl", + "title": "Soundos El Ahmadi rekent al op een tweede seizoen van All Stars & Zonen - NU.nl", + "description": "Het moet volgens comédienne Soundos El Ahmadi heel vreemd lopen wil All Stars & Zonen geen tweede seizoen krijgen. De actrice heeft een hoofdrol in het vervolg van All Stars en gaat er al voor de eerste uitzending van uit dat de nieuwe reeks aanslaat.", + "url": "https://www.nu.nl/film/6077372/soundos-el-ahmadi-rekent-al-op-een-tweede-seizoen-van-all-stars-zonen.html", + "urlToImage": "https://media.nu.nl/m/gp3xy7vat7vl_wd1280.jpg/soundos-el-ahmadi-rekent-al-op-een-tweede-seizoen-van-all-stars-zonen.jpg", + "publishedAt": "2020-09-20T09:17:00Z", + "content": "Het moet volgens comédienne Soundos El Ahmadi heel vreemd lopen wil All Stars & Zonen geen tweede seizoen krijgen. De actrice heeft een hoofdrol in het vervolg van All Stars en gaat er al voor de… [+671 chars]" + }, + { + "source": { + "id": "google-news", + "name": "Google News" + }, + "author": null, + "title": "'We zaten net naar Netflix te kijken en plotseling zit je zelf in zo'n film'; maar in Kiel-Windeweer is de rust na een schietpartij teruggekeerd - Dagblad van het Noorden", + "description": null, + "url": "https://news.google.com/__i/rss/rd/articles/CBMidWh0dHBzOi8vd3d3LmR2aG4ubmwvZ3JvbmluZ2VuL1dlLXphdGVuLW5ldC1uYWFyLU5ldGZsaXgtdGUta2lqa2VuLWVuLXBsb3RzZWxpbmcteml0LWplLXplbGYtaW4tem9uLWZpbG0tMjYwMzI3OTkuaHRtbNIBAA?oc=5", + "urlToImage": null, + "publishedAt": "2020-09-20T08:52:00Z", + "content": null + }, + { + "source": { + "id": null, + "name": "Dpgmedia.nl" + }, + "author": null, + "title": "Automobilist overlijdt door aanrijding tegen monument in Stadspark Groningen - AD.nl", + "description": null, + "url": "https://myprivacy.dpgmedia.nl/consent/?siteKey=V9f6VUvlHxq9wKIN&callbackUrl=https:%2f%2fwww.ad.nl%2fprivacy-gate%2faccept-tcf2%3fredirectUri%3d%252fgroningen%252fautomobilist-overlijdt-door-aanrijding-tegen-monument-in-stadspark-groningen%257eac916808%252f", + "urlToImage": null, + "publishedAt": "2020-09-20T08:13:17Z", + "content": null + }, + { + "source": { + "id": null, + "name": "Tpo.nl" + }, + "author": "Peil.nl", + "title": "Opiniepeiling Maurice de Hond: Wilders sterkste oppositieleider bij Algemene Beschouwingen - ThePostOnline", + "description": "Slechts 21% wil dat huidig kabinet na de verkiezingen doorgaat", + "url": "https://tpo.nl/2020/09/20/peiling-maurice-de-hond-wilders-na-rutte-het-sterkst/", + "urlToImage": "https://tpo.nl/wp-content/uploads/2020/09/geert-wilders-peil.png", + "publishedAt": "2020-09-20T07:55:45Z", + "content": "De peiling van deze week laat wederom kleine verschuivingen zien, maar ook na de Algemene Beschouwingen zien we geen structurele wijzigingen. De VVD blijft ruim voorop nummer 2 de PVV. De PvdA staat … [+483 chars]" + }, + { + "source": { + "id": null, + "name": "Voetbalzone.nl" + }, + "author": null, + "title": "Frank de Boer lijkt enige kandidaat voor Oranje met tweede gesprek op komst - Voetbalzone.nl", + "description": "", + "url": "https://www.voetbalzone.nl/doc.asp?uid=377347", + "urlToImage": "https://static.voetbalzone.nl/images/photos/ori_1152_648/132351093515435.jpg", + "publishedAt": "2020-09-20T07:53:00Z", + "content": "Er staat maandag een tweede gesprek tussen de KNVB en Frank de Boer op het programma. Pieter Zwart, hoofdredacteur van Voetbal International, vertelt in de liveshow van het weekblad dat er afgelopen … [+2136 chars]" + }, + { + "source": { + "id": null, + "name": "Nos.nl" + }, + "author": null, + "title": "Voor het eerst weer vorst aan de grond gemeten - NOS", + "description": "Bij een KNMI-weerstation in Twente werd vanmorgen om 07.20 uur kort -1,3 graden Celsius gemeten op klomphoogte.", + "url": "https://nos.nl/l/2349109", + "urlToImage": "https://nos.nl/data/image/2020/09/20/677123/xxl.jpg", + "publishedAt": "2020-09-20T07:10:00Z", + "content": "Weerplaza noemt het geen uitzondering dat op dit moment in het jaar de eerste vorst wordt gemeten. In 2012 werd half september zelfs bijna 5 graden vorst aan de grond gemeten.\r\nAfgelopen dinsdag was … [+572 chars]" + }, + { + "source": { + "id": null, + "name": "Dpgmedia.nl" + }, + "author": null, + "title": "Sylvie Meis geeft jawoord in romantisch Toscane: bekijk de foto's hier - AD.nl", + "description": null, + "url": "https://myprivacy.dpgmedia.nl/consent/?siteKey=V9f6VUvlHxq9wKIN&callbackUrl=https:%2f%2fwww.ad.nl%2fprivacy-gate%2faccept-tcf2%3fredirectUri%3d%252fhome%252fsylvie-meis-geeft-jawoord-in-romantisch-toscane-bekijk-de-foto-s-hier%257ea9e9c3d8%252f", + "urlToImage": null, + "publishedAt": "2020-09-20T07:01:35Z", + "content": null + }, + { + "source": { + "id": null, + "name": "Dagelijksestandaard.nl" + }, + "author": "", + "title": "Trump wil komende week nieuwe opvolger Hooggerechtshof kiezen: 'waarschijnlijk een vrouw' - De Dagelijkse Standaard", + "description": "Als het aan de Amerikaanse president Donald Trump ligt dan wordt er zo snel mogelijk een nieuwe opvolger voor het Hooggerechtshof aangewezen. Afgelopen week overleed rechter Ruth Bader Ginsburg, en voor de Trump-regering is dit een gouden kans om een progress…", + "url": "https://www.dagelijksestandaard.nl/2020/09/trump-wil-komende-week-nieuwe-opvolger-hooggerechtshof-kiezen-waarschijnlijk-een-vrouw/", + "urlToImage": "https://cdn-04.dagelijksestandaard.nl/wp-content/uploads/2020/08/vlcsnap-2020-08-04-07h55m32s542.jpg?x90889", + "publishedAt": "2020-09-20T07:00:43Z", + "content": "Als het aan de Amerikaanse president Donald Trump ligt dan wordt er zo snel mogelijk een nieuwe opvolger voor het Hooggerechtshof aangewezen. Afgelopen week overleed rechter Ruth Bader Ginsburg, en v… [+1101 chars]" + }, + { + "source": { + "id": null, + "name": "Dpgmedia.nl" + }, + "author": null, + "title": "In 7 foto's toont Marijn (22) de hel op Lesbos: 'Ik zat regelmatig even te huilen' - De Gelderlander", + "description": null, + "url": "https://myprivacy.dpgmedia.nl/consent/?siteKey=Oom2kBLTny5zJUeO&callbackUrl=https:%2f%2fwww.gelderlander.nl%2fprivacy-gate%2faccept-tcf2%3fredirectUri%3d%252fhome%252fin-7-foto-s-toont-marijn-22-de-hel-op-lesbos-ik-zat-regelmatig-even-te-huilen%257ea9f9da5a%252f", + "urlToImage": null, + "publishedAt": "2020-09-20T06:31:00Z", + "content": null + }, + { + "source": { + "id": null, + "name": "Www.nu.nl" + }, + "author": "NU.nl", + "title": "Pogacar zet Tour op zijn kop in sensationele tijdrit: 'Koers was al voorbij' - NU.nl", + "description": "De strijd om de eindzege leek al beslist voor de tijdrit op de voorlaatste dag van de Tour de France, maar op een sensationele zaterdag in de Vogezen brak Tadej Pogacar de gele droom van Jumbo-Visma en Primoz Roglic in duizend stukjes. Een terugblik op een va…", + "url": "https://www.nu.nl/tour-de-france/6078609/pogacar-zet-tour-op-zijn-kop-in-sensationele-tijdrit-koers-was-al-voorbij.html", + "urlToImage": "https://media.nu.nl/m/s3vx5u9auz1g_wd1280.jpg/pogacar-zet-tour-op-zijn-kop-in-sensationele-tijdrit-koers-was-al-voorbij.jpg", + "publishedAt": "2020-09-20T06:31:00Z", + "content": "De strijd om de eindzege leek al beslist voor de tijdrit op de voorlaatste dag van de Tour de France, maar op een sensationele zaterdag in de Vogezen brak Tadej Pogacar de gele droom van Jumbo-Visma … [+4978 chars]" + }, + { + "source": { + "id": null, + "name": "Nos.nl" + }, + "author": null, + "title": "VS kondigt op eigen houtje VN-sancties af tegen Iran - NOS", + "description": "Het land beroept zich op de Iran-deal uit 2015, maar volgens andere VN-diplomaten is dat niet meer mogelijk omdat Trump eruit is gestapt.", + "url": "https://nos.nl/l/2349102", + "urlToImage": "https://nos.nl/data/image/2020/09/20/677107/xxl.jpg", + "publishedAt": "2020-09-20T06:05:00Z", + "content": "De VS eist dat landen zich weer gaan houden aan VN-sancties die golden voor Iran. \"Dit is een stap op weg naar internationale vrede en veiligheid\", zegt minister Pompeo van Buitenlandse Zaken. De Rus… [+1354 chars]" + }, + { + "source": { + "id": "rtl-nieuws", + "name": "RTL Nieuws" + }, + "author": null, + "title": "Dit gebeurde er in de nacht op Le Mans! - RTL Nieuws", + "description": "De nachtelijke uren in de 24 uur van Le Mans brachten onder andere problemen voor de leidende Toyota, maar helaas ook voor Racing Team Nederland en het team van Job van Uitert. Een terugblik!", + "url": "https://www.rtlnieuws.nl/sport/gp/video/5184877/dit-gebeurde-er-de-nacht-op-le-mans", + "urlToImage": "https://screenshots.rtl.nl/system/thumb/sz=720x404/uuid=d3bff244-8f18-4814-be5c-d9e249db9632", + "publishedAt": "2020-09-20T05:52:44Z", + "content": "De nachtelijke uren in de 24 uur van Le Mans brachten onder andere problemen voor de leidende Toyota, maar helaas ook voor Racing Team Nederland en het team van Job van Uitert. Een terugblik! \r\n20 se… [+17 chars]" + }, + { + "source": { + "id": null, + "name": "Linda.nl" + }, + "author": null, + "title": "Molloot Ivo over aflevering drie: 'Ellie ontpopt zich als de Mol-Mata Hari' - LINDA.", + "description": "Tien kandidaten, van wie één Mol. Journalist Ivo over de derde aflevering van ‘Wie is de Mol?-Renaissance’:", + "url": "https://www.linda.nl/nieuws/fragment-gemist/molloot-ivo-aflevering-drie-ellie-mol-mata-hari/", + "urlToImage": "https://www.linda.nl/lindanl-assets/uploads/2020/09/20111952/ellie-lust-wie-is-de-mol-ivo-1800x1012.jpg", + "publishedAt": "2020-09-20T05:44:04Z", + "content": "Jeroen wist inmiddels dat Ellie zich alleen met hem in de echt wilde verbinden als hij met een flinke bruidsschat zou komen. Hij biechtte dan ook niet veel later op dat hij zichzelf verdacht had will… [+4849 chars]" + } + ] +} diff --git a/DutchNewsTests/Repositories/HeadLines/HeadlinesArticleLocalRepositoryTests.swift b/DutchNewsTests/Repositories/HeadLines/HeadlinesArticleLocalRepositoryTests.swift new file mode 100644 index 0000000..129baef --- /dev/null +++ b/DutchNewsTests/Repositories/HeadLines/HeadlinesArticleLocalRepositoryTests.swift @@ -0,0 +1,179 @@ +// +// HeadlinesArticleLocalRepositoryTests.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/24/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import RxSwift +import RxTest +import RxBlocking +import Alamofire +import Mocker +import XCTest + +@testable import DutchNews + +class HeadlinesArticleLocalRepositoryTests: XCTestCase { + + var articleRepository: ArticleRepository! + var disposeBag: DisposeBag! + + override func setUp() { + + articleRepository = HeadlinesArticleLocalRepository(storage: RepositoryDependenciesFactory.createStorage()) + disposeBag = DisposeBag() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + disposeBag = nil + articleRepository = nil + } + + func testSaveArticle() { + + let article = Article(title: "Mock 1", + author: "Unit Test", + description: "Mock object for unit test ", + source: ArticleSource(id: "", name: "DutchApps"), + url: URL(string: "https://example.com")!, + imageUrl: nil, + publishedAt: Date(), + content: "test content",type: .mock) + + do { + try articleRepository.save(article: article) + print("object saved successfully.") + }catch { + XCTFail("Error Occured in saving article with info: \(error.localizedDescription)") + } + } + + func testSaveArticles() { + + let articles = self.mockResponserFetcher(name: "HeadlineSuccessResponse") + + do { + try articleRepository.save(articles: articles) + print("articles saved successfully.") + }catch { + XCTFail("Error Occured in saving articles with info: \(error.localizedDescription)") + } + + } + + func testErrorOnSaveArticle() { + + let article = Article(title: "Mock 2", + author: "Unit Test", + description: "Mock object for unit test ", + source: ArticleSource(id: "", name: "DutchApps"), + url: URL(string: "https://example.com")!, + imageUrl: nil, + publishedAt: Date(), + content: "test content",type: .mock) + + do { + try articleRepository.save(article: article) + try articleRepository.save(article: article) + XCTFail("Expected that calling save method twise rise error.") + }catch { + XCTAssertNotNil(error, "An error must be occured.") + print("Error was happened. Fulfill expecations") + } + } + + func testRealFetchArticles() { + + let exptection = self.expectation(description: "testRealFetchArticles") + exptection.expectedFulfillmentCount = 1 + + articleRepository.fetchArticles() + .observeOn(MainScheduler.asyncInstance) + .subscribe { (event) in + switch event { + case .next(let element): + print("element ", element) + case .error(let error): + XCTFail("Error Occured with info: \(error.localizedDescription)") + exptection.fulfill() + case .completed: + print("Observer completed") + } + + exptection.fulfill() + + }.disposed(by: disposeBag) + + self.waitForExpectations(timeout: 30.0) { (error) in + XCTAssertTrue(error == nil, "Error Occured with info: \(error!.localizedDescription)") + } + + } + + func testSearchingArticleFromLocalRepository() { + + let exptection = self.expectation(description: "testRealFetchArticles") + exptection.expectedFulfillmentCount = 2 + + articleRepository.search(keyword: "Mock") + .observeOn(MainScheduler.asyncInstance) + .subscribe { (event) in + switch event { + case .next(let element): + print("element ", element) + case .error(let error): + XCTFail("Error Occured with info: \(error.localizedDescription)") + exptection.fulfill() + case .completed: + print("Observer completed") + } + + exptection.fulfill() + + }.disposed(by: disposeBag) + + articleRepository.search(keyword: "test").subscribe { (event) in + switch event { + case .next(let element): + print("element ", element) + case .error(let error): + XCTFail("Error Occured with info: \(error.localizedDescription)") + exptection.fulfill() + case .completed: + print("Observer completed") + } + + exptection.fulfill() + + }.disposed(by: disposeBag) + + self.waitForExpectations(timeout: 20.0) { (error) in + XCTAssertTrue(error == nil, "Error Occured with info: \(error!.localizedDescription)") + } + + } + + func mockResponserFetcher(name: String) -> [Article] { + let bundle = Bundle(for: type(of: self)) + + guard let data = try? Data(contentsOf: bundle.url(forResource: name, withExtension: "json")!) else { + return [] + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + guard let objs = try? decoder.decode([Article].self, from: data) else { + return [] + } + + return objs + + } + +} diff --git a/DutchNewsTests/Repositories/HeadLines/HeadlinesArticleRemoteRepositoryTests.swift b/DutchNewsTests/Repositories/HeadLines/HeadlinesArticleRemoteRepositoryTests.swift new file mode 100644 index 0000000..2e1b8e3 --- /dev/null +++ b/DutchNewsTests/Repositories/HeadLines/HeadlinesArticleRemoteRepositoryTests.swift @@ -0,0 +1,187 @@ +// +// HeadlinesArticleRemoteRepositoryTests.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import XCTest +import RxSwift +import RxTest +import RxBlocking +import Alamofire +import Mocker + +@testable import DutchNews + +class HeadlinesArticleRemoteRepositoryTests: XCTestCase { + + var articleRepository: ArticleRepository! + var disposeBag: DisposeBag! + + override func setUp() { + + articleRepository = HeadlinesArticleRemoteRepository(networkService: RepositoryDependenciesFactory.createMockAPIClient(), + authentictor: RepositoryDependenciesFactory.createAuthentictor(), + validator: RepositoryDependenciesFactory.createValidator()) + disposeBag = DisposeBag() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + disposeBag = nil + articleRepository = nil + } + + func testFetchMockArticles() { + do { + let exptection = self.expectation(description: "testFetchMockArticles") + exptection.expectedFulfillmentCount = 2 + + let data = mockResponserFetcher(name: "HeadlineSuccessResponse") + let mock = try NetworkMockBuilder(URL: "https://newsapi.org/v2/top-headlines?country=nl") + .set(method: .get) + .set(statusCode: 200) + .set(contentType: .json) + .set(data: [.get: data]) + .build() + + Mocker.register(mock) + + articleRepository.fetchArticles() + .observeOn(MainScheduler.asyncInstance) + .subscribe { (event) in + switch event { + case .next(let element): + print("element ", element) + case .error(let error): + XCTFail("Error Occured with info: \(error.localizedDescription)") + exptection.fulfill() + case .completed: + print("Observer completed") + } + + exptection.fulfill() + + }.disposed(by: disposeBag) + + self.waitForExpectations(timeout: 30.0) { (error) in + XCTAssertNil(error, "Error Occured with info: \(error!.localizedDescription)") + } + + } catch let error { + XCTFail("Error Occured with info: \(error.localizedDescription)") + } + } + + func testErrorOnFetchMockArticles() { + do { + let exptection = self.expectation(description: "testErrorOnFetchMockArticles") + exptection.expectedFulfillmentCount = 2 + let data = mockResponserFetcher(name: "HeadlineFailureResponse") + let mock = try NetworkMockBuilder(URL: AppConfig.BaseURL.absoluteString + "top-headlines?country=nl") + .set(method: .get) + .set(statusCode: 401) + .set(contentType: .json) + .set(data: [.get: data]) + .build() + + Mocker.register(mock) + + articleRepository.fetchArticles() + .observeOn(MainScheduler.asyncInstance) + .subscribe { (event) in + + switch event { + case .next(let element): + XCTFail("Stream emitted Element: \(element)") + case .error(let error): + print("Error Occured with info: \(error.localizedDescription)") + exptection.fulfill() + case .completed: + print("Observer completed") + } + + exptection.fulfill() + }.disposed(by: disposeBag) + + self.waitForExpectations(timeout: 30.0) { (error) in + XCTAssertNil(error, "Error Occured with info: \(error!.localizedDescription)") + } + + } catch let error { + XCTFail("Error Occured with info: \(error.localizedDescription)") + } + } + + func testRealFetchArticles() { + + let exptection = self.expectation(description: "testRealFetchArticles") + exptection.expectedFulfillmentCount = 2 + + let articleRepository = HeadlinesArticleRemoteRepository(networkService: RepositoryDependenciesFactory.createAPIClient(), + authentictor: RepositoryDependenciesFactory.createAuthentictor(), + validator: RepositoryDependenciesFactory.createValidator()) + + articleRepository.fetchArticles() + .observeOn(MainScheduler.asyncInstance) + .subscribe { (event) in + switch event { + case .next(let element): + print("element ", element) + case .error(let error): + XCTFail("Error Occured with info: \(error.localizedDescription)") + exptection.fulfill() + case .completed: + print("Observer completed") + } + + exptection.fulfill() + + }.disposed(by: disposeBag) + + self.waitForExpectations(timeout: 30.0) { (error) in + XCTAssertNil(error, "Error Occured with info: \(error!.localizedDescription)") + } + + } + + func testErrorOnFetchArticles() { + let exptection = self.expectation(description: "testErrorOnFetchArticles") + exptection.expectedFulfillmentCount = 2 + + let articleRepository = HeadlinesArticleRemoteRepository(networkService: RepositoryDependenciesFactory.createAPIClient(), + authentictor: APIAuthenticator(token: ""), + validator: RepositoryDependenciesFactory.createValidator()) + + articleRepository.fetchArticles() + .observeOn(MainScheduler.asyncInstance) + .subscribe { (event) in + + switch event { + case .next(let element): + XCTFail("Stream emitted Element: \(element)") + case .error(let error): + print("Error Occured with info: \(error.localizedDescription)") + exptection.fulfill() + case .completed: + print("Observer completed") + } + + exptection.fulfill() + }.disposed(by: disposeBag) + + self.waitForExpectations(timeout: 30.0) { (error) in + XCTAssertNil(error, "Error Occured with info: \(error!.localizedDescription)") + } + + } + + func mockResponserFetcher(name: String) -> Data { + let bundle = Bundle(for: type(of: self)) + return try! Data(contentsOf: bundle.url(forResource: name, withExtension: "json")!) + } + +} diff --git a/DutchNewsTests/Repositories/MockArticleValidResponse.swift b/DutchNewsTests/Repositories/MockArticleValidResponse.swift new file mode 100644 index 0000000..d431656 --- /dev/null +++ b/DutchNewsTests/Repositories/MockArticleValidResponse.swift @@ -0,0 +1,17 @@ +// +// MockArticleValidResponse.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +@testable import DutchNews + +struct MockArticleValidResponse: NetworkValidResponse { + var statusCodes: Set { + Set((200..<300).map { $0 } + [400, 422, 429, 401]) + } + var contentTypes: [String] { ["application/json"] } +} diff --git a/DutchNewsTests/Repositories/RepositoryDependenciesFactory.swift b/DutchNewsTests/Repositories/RepositoryDependenciesFactory.swift new file mode 100644 index 0000000..daeaef1 --- /dev/null +++ b/DutchNewsTests/Repositories/RepositoryDependenciesFactory.swift @@ -0,0 +1,46 @@ +// +// RepositoryDependenciesFactory.swift +// DutchNewsTests +// +// Created by Farshad Mousalou on 9/20/20. +// Copyright © 2020 Farshad Mousalou. All rights reserved. +// + +import Foundation +import Alamofire +import Mocker + +@testable import DutchNews + +struct RepositoryDependenciesFactory { + + static func createMockAPIClient() -> NetworkServiceInterceptable { + + let configuration = URLSessionConfiguration.af.default + configuration.protocolClasses = [MockingURLProtocol.self] + let sessionManager = Alamofire.Session(configuration: configuration) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return APIClientService(baseURL: AppConfig.BaseURL, + session: sessionManager, decoder: decoder) + } + + static func createAPIClient() -> NetworkServiceInterceptable { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return APIClientService(baseURL: AppConfig.BaseURL, decoder: decoder) + } + + static func createAuthentictor() -> RequestInterceptor { + return APIAuthenticator(token: AppConfig.APIKey) + } + + static func createValidator() -> NetworkValidResponse { + return MockArticleValidResponse() + } + + static func createStorage() -> Storage { + return CodableDataManager.default + } + +} diff --git a/DutchNewsUITests/DutchNewsUITests.swift b/DutchNewsUITests/DutchNewsUITests.swift deleted file mode 100644 index b8b7246..0000000 --- a/DutchNewsUITests/DutchNewsUITests.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// DutchNewsUITests.swift -// DutchNewsUITests -// -// Created by Farshad Mousalou on 9/12/20. -// Copyright © 2020 Farshad Mousalou. All rights reserved. -// - -import XCTest - -class DutchNewsUITests: XCTestCase { - - override func setUp() { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use recording to get started writing UI tests. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testLaunchPerformance() { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { - XCUIApplication().launch() - } - } - } -} diff --git a/DutchNewsUITests/Info.plist b/DutchNewsUITests/Info.plist deleted file mode 100644 index 64d65ca..0000000 --- a/DutchNewsUITests/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c7de801 --- /dev/null +++ b/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +gem "fastlane" +gem "xcode-install" +gem "cocoapods" +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..09bf10f --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,249 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.2) + activesupport (4.2.11.3) + i18n (~> 0.7) + minitest (~> 5.1) + thread_safe (~> 0.3, >= 0.3.4) + tzinfo (~> 1.1) + addressable (2.7.0) + public_suffix (>= 2.0.2, < 5.0) + algoliasearch (1.27.4) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + aws-eventstream (1.1.0) + aws-partitions (1.371.0) + aws-sdk-core (3.107.0) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.38.0) + aws-sdk-core (~> 3, >= 3.99.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.81.0) + aws-sdk-core (~> 3, >= 3.104.3) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.2.2) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.3) + claide (1.0.3) + cocoapods (1.9.3) + activesupport (>= 4.0.2, < 5) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.9.3) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 1.2.2, < 2.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-stats (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.4.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.6.6) + nap (~> 1.0) + ruby-macho (~> 1.4) + xcodeproj (>= 1.14.0, < 2.0) + cocoapods-core (1.9.3) + activesupport (>= 4.0.2, < 6) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.4) + cocoapods-downloader (1.4.0) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.0) + cocoapods-stats (1.1.0) + cocoapods-trunk (1.5.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored (1.2) + colored2 (3.1.2) + commander-fastlane (4.4.6) + highline (~> 1.7.2) + concurrent-ruby (1.1.7) + declarative (0.0.20) + declarative-option (0.1.0) + digest-crc (0.6.1) + rake (~> 13.0) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) + dotenv (2.7.6) + emoji_regex (3.0.0) + escape (0.0.4) + ethon (0.12.0) + ffi (>= 1.3.0) + excon (0.76.0) + faraday (1.0.1) + multipart-post (>= 1.2, < 3) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday_middleware (1.0.0) + faraday (~> 1.0) + fastimage (2.2.0) + fastlane (2.160.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.3, < 3.0.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored + commander-fastlane (>= 4.4.6, < 5.0.0) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-api-client (>= 0.37.0, < 0.39.0) + google-cloud-storage (>= 1.15.0, < 2.0.0) + highline (>= 1.7.2, < 2.0.0) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (~> 2.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.3) + simctl (~> 1.6.3) + slack-notifier (>= 2.0.0, < 3.0.0) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (>= 1.4.5, < 2.0.0) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.3.0) + xcpretty-travis-formatter (>= 0.0.3) + fastlane-plugin-xchtmlreport (0.1.1) + ffi (1.13.1) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + google-api-client (0.38.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 0.9) + httpclient (>= 2.8.1, < 3.0) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.0) + signet (~> 0.12) + google-cloud-core (1.5.0) + google-cloud-env (~> 1.0) + google-cloud-errors (~> 1.0) + google-cloud-env (1.3.3) + faraday (>= 0.17.3, < 2.0) + google-cloud-errors (1.0.1) + google-cloud-storage (1.28.0) + addressable (~> 2.5) + digest-crc (~> 0.4) + google-api-client (~> 0.33) + google-cloud-core (~> 1.2) + googleauth (~> 0.9) + mini_mime (~> 1.0) + googleauth (0.13.1) + faraday (>= 0.17.3, < 2.0) + jwt (>= 1.4, < 3.0) + memoist (~> 0.16) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (~> 0.14) + highline (1.7.10) + http-cookie (1.0.3) + domain_name (~> 0.5) + httpclient (2.8.3) + i18n (0.9.5) + concurrent-ruby (~> 1.0) + jmespath (1.4.0) + json (2.3.0) + jwt (2.2.2) + memoist (0.16.2) + mini_magick (4.10.1) + mini_mime (1.0.2) + minitest (5.14.2) + molinillo (0.6.6) + multi_json (1.15.0) + multipart-post (2.0.0) + nanaimo (0.3.0) + nap (1.1.0) + naturally (2.2.0) + netrc (0.11.0) + os (1.1.1) + plist (3.5.0) + public_suffix (4.0.6) + rake (13.0.1) + representable (3.0.4) + declarative (< 0.1.0) + declarative-option (< 0.2.0) + uber (< 0.2.0) + retriable (3.1.2) + rouge (2.0.7) + ruby-macho (1.4.0) + rubyzip (2.3.0) + security (0.1.3) + signet (0.14.0) + addressable (~> 2.3) + faraday (>= 0.17.3, < 2.0) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.8) + CFPropertyList + naturally + slack-notifier (2.3.2) + terminal-notifier (2.0.0) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + thread_safe (0.3.6) + tty-cursor (0.7.1) + tty-screen (0.8.1) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + typhoeus (1.4.0) + ethon (>= 0.9.0) + tzinfo (1.2.7) + thread_safe (~> 0.1) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.7) + unicode-display_width (1.7.0) + word_wrap (1.0.0) + xcode-install (2.6.6) + claide (>= 0.9.1, < 1.1.0) + fastlane (>= 2.1.0, < 3.0.0) + xcodeproj (1.18.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + xcpretty (0.3.0) + rouge (~> 2.0.7) + xcpretty-travis-formatter (1.0.0) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + ruby + +DEPENDENCIES + cocoapods + fastlane + fastlane-plugin-xchtmlreport + xcode-install + +BUNDLED WITH + 2.1.4 diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..4e90a13 --- /dev/null +++ b/Podfile @@ -0,0 +1,49 @@ +# Uncomment the next line to define a global platform for your project +platform :ios, '11.0' + +# ignore all warnings from all dependencies +inhibit_all_warnings! + +# supportet swift versions +supports_swift_versions '>= 4.0' + +# Comment the next line if you don't want to use dynamic frameworks +use_frameworks! + +target 'DutchNews' do + + inhibit_all_warnings! + # Pods for DutchNews + + pod 'Alamofire' + pod 'RxSwift' + pod 'RxCocoa' + pod 'RxDataSources' + pod 'RxAlamofire' + pod 'PureLayout' + pod 'MXParallaxHeader' + pod 'Pageboy' +# pod 'JEKScrollableSectionCollectionViewLayout', :git => 'https://github.com/farshadmb/JEKScrollableSectionCollectionViewLayout.git' + pod 'MagazineLayout', :git => 'https://github.com/farshadmb/MagazineLayout.git' + + pod 'MaterialComponents' + pod 'SwiftLint' + pod 'CryptoSwift', '1.1.2' + pod 'SDWebImage' + + #Logger Framework + pod 'CocoaLumberjack/Swift' + +end + +target 'DutchNewsTests' do + inherit! :complete + pod 'RxAlamofire' + pod 'RxTest' + pod 'RxBlocking' + pod 'Nimble' + pod 'Mocker', '~> 1.0.0' + + # Pods for testing +end + diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 0000000..a9ccf64 --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,761 @@ +PODS: + - Alamofire (5.2.1) + - CocoaLumberjack/Core (3.6.1) + - CocoaLumberjack/Swift (3.6.1): + - CocoaLumberjack/Core + - CryptoSwift (1.1.2) + - Differentiator (4.0.1) + - MagazineLayout (1.6.2) + - MaterialComponents (109.8.0): + - MaterialComponents/ActionSheet (= 109.8.0) + - "MaterialComponents/ActionSheet+Theming (= 109.8.0)" + - MaterialComponents/ActivityIndicator (= 109.8.0) + - MaterialComponents/AnimationTiming (= 109.8.0) + - MaterialComponents/AppBar (= 109.8.0) + - "MaterialComponents/AppBar+ColorThemer (= 109.8.0)" + - "MaterialComponents/AppBar+Theming (= 109.8.0)" + - "MaterialComponents/AppBar+TypographyThemer (= 109.8.0)" + - MaterialComponents/Availability (= 109.8.0) + - MaterialComponents/Banner (= 109.8.0) + - "MaterialComponents/Banner+Theming (= 109.8.0)" + - MaterialComponents/BottomAppBar (= 109.8.0) + - MaterialComponents/BottomNavigation (= 109.8.0) + - "MaterialComponents/BottomNavigation+Theming (= 109.8.0)" + - MaterialComponents/BottomSheet (= 109.8.0) + - "MaterialComponents/BottomSheet+ShapeThemer (= 109.8.0)" + - MaterialComponents/ButtonBar (= 109.8.0) + - MaterialComponents/Buttons (= 109.8.0) + - "MaterialComponents/Buttons+ButtonThemer (= 109.8.0)" + - "MaterialComponents/Buttons+ColorThemer (= 109.8.0)" + - "MaterialComponents/Buttons+ShapeThemer (= 109.8.0)" + - "MaterialComponents/Buttons+Theming (= 109.8.0)" + - "MaterialComponents/Buttons+TitleColorAccessibilityMutator (= 109.8.0)" + - "MaterialComponents/Buttons+TypographyThemer (= 109.8.0)" + - MaterialComponents/Cards (= 109.8.0) + - "MaterialComponents/Cards+Theming (= 109.8.0)" + - MaterialComponents/Chips (= 109.8.0) + - "MaterialComponents/Chips+Theming (= 109.8.0)" + - MaterialComponents/CollectionCells (= 109.8.0) + - MaterialComponents/CollectionLayoutAttributes (= 109.8.0) + - MaterialComponents/Collections (= 109.8.0) + - MaterialComponents/Dialogs (= 109.8.0) + - "MaterialComponents/Dialogs+ColorThemer (= 109.8.0)" + - "MaterialComponents/Dialogs+Theming (= 109.8.0)" + - "MaterialComponents/Dialogs+TypographyThemer (= 109.8.0)" + - MaterialComponents/Elevation (= 109.8.0) + - MaterialComponents/FeatureHighlight (= 109.8.0) + - "MaterialComponents/FeatureHighlight+ColorThemer (= 109.8.0)" + - "MaterialComponents/FeatureHighlight+FeatureHighlightAccessibilityMutator (= 109.8.0)" + - MaterialComponents/FlexibleHeader (= 109.8.0) + - "MaterialComponents/FlexibleHeader+CanAlwaysExpandToMaximumHeight (= 109.8.0)" + - "MaterialComponents/FlexibleHeader+ShiftBehavior (= 109.8.0)" + - "MaterialComponents/FlexibleHeader+ShiftBehaviorEnabledWithStatusBar (= 109.8.0)" + - MaterialComponents/HeaderStackView (= 109.8.0) + - "MaterialComponents/HeaderStackView+ColorThemer (= 109.8.0)" + - MaterialComponents/Ink (= 109.8.0) + - MaterialComponents/LibraryInfo (= 109.8.0) + - MaterialComponents/List (= 109.8.0) + - "MaterialComponents/List+Theming (= 109.8.0)" + - MaterialComponents/NavigationBar (= 109.8.0) + - "MaterialComponents/NavigationBar+ColorThemer (= 109.8.0)" + - "MaterialComponents/NavigationBar+TypographyThemer (= 109.8.0)" + - MaterialComponents/NavigationDrawer (= 109.8.0) + - "MaterialComponents/NavigationDrawer+ColorThemer (= 109.8.0)" + - "MaterialComponents/NavigationDrawer+Theming (= 109.8.0)" + - MaterialComponents/OverlayWindow (= 109.8.0) + - MaterialComponents/PageControl (= 109.8.0) + - MaterialComponents/Palettes (= 109.8.0) + - MaterialComponents/private (= 109.8.0) + - MaterialComponents/ProgressView (= 109.8.0) + - "MaterialComponents/ProgressView+Theming (= 109.8.0)" + - MaterialComponents/Ripple (= 109.8.0) + - MaterialComponents/schemes (= 109.8.0) + - MaterialComponents/ShadowElevations (= 109.8.0) + - MaterialComponents/ShadowLayer (= 109.8.0) + - MaterialComponents/ShapeLibrary (= 109.8.0) + - MaterialComponents/Shapes (= 109.8.0) + - MaterialComponents/Slider (= 109.8.0) + - "MaterialComponents/Slider+ColorThemer (= 109.8.0)" + - MaterialComponents/Snackbar (= 109.8.0) + - "MaterialComponents/Snackbar+FontThemer (= 109.8.0)" + - "MaterialComponents/Snackbar+TypographyThemer (= 109.8.0)" + - MaterialComponents/Tabs (= 109.8.0) + - "MaterialComponents/Tabs+Theming (= 109.8.0)" + - "MaterialComponents/Tabs+TypographyThemer (= 109.8.0)" + - "MaterialComponents/TextControls+BaseTextAreas (= 109.8.0)" + - "MaterialComponents/TextControls+BaseTextFields (= 109.8.0)" + - "MaterialComponents/TextControls+Enums (= 109.8.0)" + - "MaterialComponents/TextControls+FilledTextAreas (= 109.8.0)" + - "MaterialComponents/TextControls+FilledTextAreasTheming (= 109.8.0)" + - "MaterialComponents/TextControls+FilledTextFields (= 109.8.0)" + - "MaterialComponents/TextControls+FilledTextFieldsTheming (= 109.8.0)" + - "MaterialComponents/TextControls+OutlinedTextAreas (= 109.8.0)" + - "MaterialComponents/TextControls+OutlinedTextAreasTheming (= 109.8.0)" + - "MaterialComponents/TextControls+OutlinedTextFields (= 109.8.0)" + - "MaterialComponents/TextControls+OutlinedTextFieldsTheming (= 109.8.0)" + - "MaterialComponents/TextControls+UnderlinedTextFields (= 109.8.0)" + - "MaterialComponents/TextControls+UnderlinedTextFieldsTheming (= 109.8.0)" + - MaterialComponents/TextFields (= 109.8.0) + - "MaterialComponents/TextFields+ColorThemer (= 109.8.0)" + - "MaterialComponents/TextFields+Theming (= 109.8.0)" + - MaterialComponents/Themes (= 109.8.0) + - MaterialComponents/Typography (= 109.8.0) + - MaterialComponents/ActionSheet (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/BottomSheet + - MaterialComponents/Elevation + - MaterialComponents/Ink + - MaterialComponents/private/Math + - MaterialComponents/Ripple + - MaterialComponents/ShadowElevations + - MaterialComponents/Typography + - "MaterialComponents/ActionSheet+Theming (109.8.0)": + - MaterialComponents/ActionSheet + - MaterialComponents/Availability + - MaterialComponents/Elevation + - MaterialComponents/private/Color + - MaterialComponents/schemes/Container + - MaterialComponents/ShadowElevations + - MaterialComponents/ActivityIndicator (109.8.0): + - MaterialComponents/Palettes + - MaterialComponents/private/Application + - MDFInternationalization + - MotionAnimator (~> 2.0) + - MaterialComponents/AnimationTiming (109.8.0) + - MaterialComponents/AppBar (109.8.0): + - MaterialComponents/FlexibleHeader + - MaterialComponents/HeaderStackView + - MaterialComponents/NavigationBar + - MaterialComponents/private/Application + - MaterialComponents/private/Icons/ic_arrow_back + - MaterialComponents/private/UIMetrics + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/Typography + - MDFInternationalization + - "MaterialComponents/AppBar+ColorThemer (109.8.0)": + - MaterialComponents/AppBar + - "MaterialComponents/NavigationBar+ColorThemer" + - MaterialComponents/Themes + - "MaterialComponents/AppBar+Theming (109.8.0)": + - MaterialComponents/AppBar + - MaterialComponents/schemes/Container + - "MaterialComponents/AppBar+TypographyThemer (109.8.0)": + - MaterialComponents/AppBar + - "MaterialComponents/NavigationBar+TypographyThemer" + - MaterialComponents/Availability (109.8.0) + - MaterialComponents/Banner (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/Buttons + - MaterialComponents/Elevation + - MaterialComponents/Typography + - "MaterialComponents/Banner+Theming (109.8.0)": + - MaterialComponents/Banner + - MaterialComponents/Buttons + - "MaterialComponents/Buttons+Theming" + - MaterialComponents/Elevation + - MaterialComponents/schemes/Container + - MaterialComponents/Typography + - MaterialComponents/BottomAppBar (109.8.0): + - MaterialComponents/Buttons + - MaterialComponents/NavigationBar + - MaterialComponents/private/Math + - MDFInternationalization + - MaterialComponents/BottomNavigation (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/Elevation + - MaterialComponents/Ink + - MaterialComponents/Palettes + - MaterialComponents/private/Math + - MaterialComponents/Ripple + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/Typography + - MDFInternationalization + - "MaterialComponents/BottomNavigation+Theming (109.8.0)": + - MaterialComponents/BottomNavigation + - MaterialComponents/schemes/Color + - MaterialComponents/schemes/Container + - MaterialComponents/schemes/Typography + - MaterialComponents/ShadowElevations + - MaterialComponents/BottomSheet (109.8.0): + - MaterialComponents/Elevation + - MaterialComponents/private/KeyboardWatcher + - MaterialComponents/private/Math + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/ShapeLibrary + - MaterialComponents/Shapes + - "MaterialComponents/BottomSheet+ShapeThemer (109.8.0)": + - MaterialComponents/BottomSheet + - MaterialComponents/schemes/Shape + - MaterialComponents/ButtonBar (109.8.0): + - MaterialComponents/Buttons + - MaterialComponents/private/Application + - MDFInternationalization + - MaterialComponents/Buttons (109.8.0): + - MaterialComponents/Elevation + - MaterialComponents/Ink + - MaterialComponents/private/Math + - MaterialComponents/Ripple + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/ShapeLibrary + - MaterialComponents/Shapes + - MaterialComponents/Typography + - MDFInternationalization + - MDFTextAccessibility + - "MaterialComponents/Buttons+ButtonThemer (109.8.0)": + - MaterialComponents/Buttons + - "MaterialComponents/Buttons+ColorThemer" + - "MaterialComponents/Buttons+ShapeThemer" + - "MaterialComponents/Buttons+TypographyThemer" + - MaterialComponents/Palettes + - "MaterialComponents/Buttons+ColorThemer (109.8.0)": + - MaterialComponents/Buttons + - MaterialComponents/schemes/Color + - "MaterialComponents/Buttons+ShapeThemer (109.8.0)": + - MaterialComponents/Buttons + - MaterialComponents/schemes/Shape + - "MaterialComponents/Buttons+Theming (109.8.0)": + - MaterialComponents/Buttons + - "MaterialComponents/Buttons+ColorThemer" + - "MaterialComponents/Buttons+ShapeThemer" + - "MaterialComponents/Buttons+TypographyThemer" + - MaterialComponents/schemes/Container + - MaterialComponents/ShadowElevations + - "MaterialComponents/Buttons+TitleColorAccessibilityMutator (109.8.0)": + - MaterialComponents/Buttons + - MDFTextAccessibility + - "MaterialComponents/Buttons+TypographyThemer (109.8.0)": + - MaterialComponents/Buttons + - MaterialComponents/schemes/Typography + - MaterialComponents/Cards (109.8.0): + - MaterialComponents/Elevation + - MaterialComponents/Ink + - MaterialComponents/private/Icons/ic_check_circle + - MaterialComponents/private/Math + - MaterialComponents/Ripple + - MaterialComponents/ShadowLayer + - MaterialComponents/Shapes + - "MaterialComponents/Cards+Theming (109.8.0)": + - MaterialComponents/Cards + - MaterialComponents/schemes/Container + - MaterialComponents/Chips (109.8.0): + - MaterialComponents/Elevation + - MaterialComponents/Ink + - MaterialComponents/private/Math + - MaterialComponents/Ripple + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/ShapeLibrary + - MaterialComponents/Shapes + - MaterialComponents/TextFields + - MaterialComponents/Typography + - MDFInternationalization + - "MaterialComponents/Chips+Theming (109.8.0)": + - MaterialComponents/Chips + - MaterialComponents/schemes/Container + - MaterialComponents/Typography + - MaterialComponents/CollectionCells (109.8.0): + - MaterialComponents/CollectionLayoutAttributes + - MaterialComponents/Ink + - MaterialComponents/Palettes + - MaterialComponents/private/Icons/ic_check + - MaterialComponents/private/Icons/ic_check_circle + - MaterialComponents/private/Icons/ic_chevron_right + - MaterialComponents/private/Icons/ic_info + - MaterialComponents/private/Icons/ic_radio_button_unchecked + - MaterialComponents/private/Icons/ic_reorder + - MaterialComponents/private/Math + - MaterialComponents/Typography + - MDFInternationalization + - MaterialComponents/CollectionLayoutAttributes (109.8.0) + - MaterialComponents/Collections (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/CollectionCells + - MaterialComponents/CollectionLayoutAttributes + - MaterialComponents/Ink + - MaterialComponents/Palettes + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/Typography + - MaterialComponents/Dialogs (109.8.0): + - MaterialComponents/Buttons + - MaterialComponents/Elevation + - MaterialComponents/private/KeyboardWatcher + - MaterialComponents/private/Math + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/Typography + - MDFInternationalization + - "MaterialComponents/Dialogs+ColorThemer (109.8.0)": + - "MaterialComponents/Buttons+ColorThemer" + - MaterialComponents/Dialogs + - MaterialComponents/Themes + - "MaterialComponents/Dialogs+Theming (109.8.0)": + - "MaterialComponents/Buttons+Theming" + - MaterialComponents/Dialogs + - "MaterialComponents/Dialogs+ColorThemer" + - "MaterialComponents/Dialogs+TypographyThemer" + - MaterialComponents/schemes/Container + - MaterialComponents/ShadowElevations + - "MaterialComponents/Dialogs+TypographyThemer (109.8.0)": + - "MaterialComponents/Buttons+TypographyThemer" + - MaterialComponents/Dialogs + - MaterialComponents/schemes/Typography + - MaterialComponents/Elevation (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/private/Color + - MaterialComponents/private/Math + - MaterialComponents/FeatureHighlight (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/private/Math + - MaterialComponents/Typography + - MDFTextAccessibility + - "MaterialComponents/FeatureHighlight+ColorThemer (109.8.0)": + - MaterialComponents/FeatureHighlight + - MaterialComponents/Themes + - "MaterialComponents/FeatureHighlight+FeatureHighlightAccessibilityMutator (109.8.0)": + - MaterialComponents/FeatureHighlight + - MDFTextAccessibility + - MaterialComponents/FlexibleHeader (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/Elevation + - "MaterialComponents/FlexibleHeader+ShiftBehavior" + - "MaterialComponents/FlexibleHeader+ShiftBehaviorEnabledWithStatusBar" + - MaterialComponents/private/Application + - MaterialComponents/private/Math + - MaterialComponents/private/UIMetrics + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MDFTextAccessibility + - "MaterialComponents/FlexibleHeader+CanAlwaysExpandToMaximumHeight (109.8.0)": + - MaterialComponents/FlexibleHeader + - "MaterialComponents/FlexibleHeader+ShiftBehavior (109.8.0)" + - "MaterialComponents/FlexibleHeader+ShiftBehaviorEnabledWithStatusBar (109.8.0)": + - "MaterialComponents/FlexibleHeader+ShiftBehavior" + - MaterialComponents/HeaderStackView (109.8.0) + - "MaterialComponents/HeaderStackView+ColorThemer (109.8.0)": + - MaterialComponents/HeaderStackView + - MaterialComponents/Themes + - MaterialComponents/Ink (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/private/Color + - MaterialComponents/private/Math + - MaterialComponents/LibraryInfo (109.8.0) + - MaterialComponents/List (109.8.0): + - MaterialComponents/Elevation + - MaterialComponents/Ink + - MaterialComponents/private/Math + - MaterialComponents/Ripple + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/Typography + - MDFInternationalization + - "MaterialComponents/List+Theming (109.8.0)": + - MaterialComponents/List + - MaterialComponents/schemes/Container + - MaterialComponents/NavigationBar (109.8.0): + - MaterialComponents/ButtonBar + - MaterialComponents/Elevation + - MaterialComponents/private/Math + - MaterialComponents/Typography + - MDFInternationalization + - MDFTextAccessibility + - "MaterialComponents/NavigationBar+ColorThemer (109.8.0)": + - MaterialComponents/NavigationBar + - MaterialComponents/schemes/Color + - "MaterialComponents/NavigationBar+TypographyThemer (109.8.0)": + - MaterialComponents/NavigationBar + - MaterialComponents/schemes/Typography + - MaterialComponents/NavigationDrawer (109.8.0): + - MaterialComponents/Elevation + - MaterialComponents/Palettes + - MaterialComponents/private/Math + - MaterialComponents/private/UIMetrics + - MaterialComponents/ShadowLayer + - "MaterialComponents/NavigationDrawer+ColorThemer (109.8.0)": + - MaterialComponents/NavigationDrawer + - MaterialComponents/schemes/Color + - "MaterialComponents/NavigationDrawer+Theming (109.8.0)": + - MaterialComponents/NavigationDrawer + - MaterialComponents/schemes/Container + - MaterialComponents/OverlayWindow (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/private/Application + - MaterialComponents/PageControl (109.8.0): + - MDFInternationalization + - MaterialComponents/Palettes (109.8.0) + - MaterialComponents/private (109.8.0): + - MaterialComponents/private/Application (= 109.8.0) + - MaterialComponents/private/Color (= 109.8.0) + - MaterialComponents/private/Icons (= 109.8.0) + - MaterialComponents/private/KeyboardWatcher (= 109.8.0) + - MaterialComponents/private/Math (= 109.8.0) + - MaterialComponents/private/Overlay (= 109.8.0) + - "MaterialComponents/private/TextControlsPrivate+BaseStyle (= 109.8.0)" + - "MaterialComponents/private/TextControlsPrivate+FilledStyle (= 109.8.0)" + - "MaterialComponents/private/TextControlsPrivate+OutlinedStyle (= 109.8.0)" + - "MaterialComponents/private/TextControlsPrivate+Shared (= 109.8.0)" + - "MaterialComponents/private/TextControlsPrivate+TextFields (= 109.8.0)" + - "MaterialComponents/private/TextControlsPrivate+UnderlinedStyle (= 109.8.0)" + - MaterialComponents/private/ThumbTrack (= 109.8.0) + - MaterialComponents/private/UIMetrics (= 109.8.0) + - MaterialComponents/private/Application (109.8.0) + - MaterialComponents/private/Color (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/private/Icons (109.8.0): + - MaterialComponents/private/Icons/Base (= 109.8.0) + - MaterialComponents/private/Icons/ic_arrow_back (= 109.8.0) + - MaterialComponents/private/Icons/ic_check (= 109.8.0) + - MaterialComponents/private/Icons/ic_check_circle (= 109.8.0) + - MaterialComponents/private/Icons/ic_chevron_right (= 109.8.0) + - MaterialComponents/private/Icons/ic_color_lens (= 109.8.0) + - MaterialComponents/private/Icons/ic_help_outline (= 109.8.0) + - MaterialComponents/private/Icons/ic_info (= 109.8.0) + - MaterialComponents/private/Icons/ic_more_horiz (= 109.8.0) + - MaterialComponents/private/Icons/ic_radio_button_unchecked (= 109.8.0) + - MaterialComponents/private/Icons/ic_reorder (= 109.8.0) + - MaterialComponents/private/Icons/ic_settings (= 109.8.0) + - MaterialComponents/private/Icons/Base (109.8.0) + - MaterialComponents/private/Icons/ic_arrow_back (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_check (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_check_circle (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_chevron_right (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_color_lens (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_help_outline (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_info (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_more_horiz (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_radio_button_unchecked (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_reorder (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/Icons/ic_settings (109.8.0): + - MaterialComponents/private/Icons/Base + - MaterialComponents/private/KeyboardWatcher (109.8.0): + - MaterialComponents/private/Application + - MaterialComponents/private/Math (109.8.0) + - MaterialComponents/private/Overlay (109.8.0) + - "MaterialComponents/private/TextControlsPrivate+BaseStyle (109.8.0)": + - MaterialComponents/AnimationTiming + - MaterialComponents/private/Math + - "MaterialComponents/private/TextControlsPrivate+Shared" + - "MaterialComponents/private/TextControlsPrivate+FilledStyle (109.8.0)": + - MaterialComponents/AnimationTiming + - MaterialComponents/Availability + - MaterialComponents/private/Math + - "MaterialComponents/private/TextControlsPrivate+Shared" + - "MaterialComponents/private/TextControlsPrivate+UnderlinedStyle" + - "MaterialComponents/private/TextControlsPrivate+OutlinedStyle (109.8.0)": + - MaterialComponents/AnimationTiming + - MaterialComponents/Availability + - MaterialComponents/private/Math + - "MaterialComponents/private/TextControlsPrivate+Shared" + - "MaterialComponents/private/TextControlsPrivate+Shared (109.8.0)": + - MaterialComponents/AnimationTiming + - MaterialComponents/private/Math + - "MaterialComponents/TextControls+Enums" + - "MaterialComponents/private/TextControlsPrivate+TextFields (109.8.0)": + - MaterialComponents/private/Math + - "MaterialComponents/private/TextControlsPrivate+Shared" + - "MaterialComponents/private/TextControlsPrivate+UnderlinedStyle (109.8.0)": + - MaterialComponents/AnimationTiming + - MaterialComponents/Availability + - MaterialComponents/private/Math + - "MaterialComponents/private/TextControlsPrivate+Shared" + - MaterialComponents/private/ThumbTrack (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/Ink + - MaterialComponents/private/Math + - MaterialComponents/Ripple + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/Typography + - MDFInternationalization + - MaterialComponents/private/UIMetrics (109.8.0): + - MaterialComponents/private/Application + - MaterialComponents/ProgressView (109.8.0): + - MaterialComponents/Palettes + - MaterialComponents/private/Math + - MDFInternationalization + - "MaterialComponents/ProgressView+Theming (109.8.0)": + - MaterialComponents/ProgressView + - MaterialComponents/schemes/Container + - MaterialComponents/Ripple (109.8.0): + - MaterialComponents/AnimationTiming + - MaterialComponents/Availability + - MaterialComponents/private/Color + - MaterialComponents/private/Math + - MaterialComponents/schemes (109.8.0): + - MaterialComponents/schemes/Color (= 109.8.0) + - MaterialComponents/schemes/Container (= 109.8.0) + - MaterialComponents/schemes/Shape (= 109.8.0) + - MaterialComponents/schemes/Typography (= 109.8.0) + - "MaterialComponents/schemes/Typography+BasicFontScheme (= 109.8.0)" + - "MaterialComponents/schemes/Typography+Scheming (= 109.8.0)" + - MaterialComponents/schemes/Color (109.8.0): + - MaterialComponents/Availability + - MaterialComponents/private/Color + - MaterialComponents/schemes/Container (109.8.0): + - MaterialComponents/schemes/Color + - MaterialComponents/schemes/Shape + - MaterialComponents/schemes/Typography + - MaterialComponents/schemes/Shape (109.8.0): + - MaterialComponents/ShapeLibrary + - MaterialComponents/Shapes + - MaterialComponents/schemes/Typography (109.8.0): + - "MaterialComponents/schemes/Typography+BasicFontScheme" + - "MaterialComponents/schemes/Typography+Scheming" + - MaterialComponents/Typography + - "MaterialComponents/schemes/Typography+BasicFontScheme (109.8.0)" + - "MaterialComponents/schemes/Typography+Scheming (109.8.0)" + - MaterialComponents/ShadowElevations (109.8.0) + - MaterialComponents/ShadowLayer (109.8.0): + - MaterialComponents/ShadowElevations + - MaterialComponents/ShapeLibrary (109.8.0): + - MaterialComponents/private/Math + - MaterialComponents/Shapes + - MaterialComponents/Shapes (109.8.0): + - MaterialComponents/private/Color + - MaterialComponents/private/Math + - MaterialComponents/ShadowLayer + - MaterialComponents/Slider (109.8.0): + - MaterialComponents/Elevation + - MaterialComponents/Palettes + - MaterialComponents/private/Math + - MaterialComponents/private/ThumbTrack + - MaterialComponents/ShadowElevations + - "MaterialComponents/Slider+ColorThemer (109.8.0)": + - MaterialComponents/Palettes + - MaterialComponents/schemes/Color + - MaterialComponents/Slider + - MaterialComponents/Snackbar (109.8.0): + - MaterialComponents/AnimationTiming + - MaterialComponents/Availability + - MaterialComponents/Buttons + - MaterialComponents/Elevation + - MaterialComponents/OverlayWindow + - MaterialComponents/private/Application + - MaterialComponents/private/KeyboardWatcher + - MaterialComponents/private/Math + - MaterialComponents/private/Overlay + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/Typography + - "MaterialComponents/Snackbar+FontThemer (109.8.0)": + - MaterialComponents/Snackbar + - MaterialComponents/Themes + - "MaterialComponents/Snackbar+TypographyThemer (109.8.0)": + - MaterialComponents/schemes/Typography + - MaterialComponents/Snackbar + - MaterialComponents/Tabs (109.8.0): + - MaterialComponents/AnimationTiming + - MaterialComponents/Elevation + - MaterialComponents/Ink + - MaterialComponents/Palettes + - MaterialComponents/private/Math + - MaterialComponents/Ripple + - MaterialComponents/ShadowElevations + - MaterialComponents/ShadowLayer + - MaterialComponents/Typography + - MDFInternationalization + - "MaterialComponents/Tabs+Theming (109.8.0)": + - MaterialComponents/schemes/Container + - MaterialComponents/Tabs + - "MaterialComponents/Tabs+TypographyThemer" + - "MaterialComponents/Tabs+TypographyThemer (109.8.0)": + - MaterialComponents/schemes/Typography + - MaterialComponents/Tabs + - "MaterialComponents/TextControls+BaseTextAreas (109.8.0)": + - "MaterialComponents/private/TextControlsPrivate+BaseStyle" + - "MaterialComponents/private/TextControlsPrivate+Shared" + - MDFInternationalization + - "MaterialComponents/TextControls+BaseTextFields (109.8.0)": + - "MaterialComponents/private/TextControlsPrivate+BaseStyle" + - "MaterialComponents/private/TextControlsPrivate+Shared" + - "MaterialComponents/private/TextControlsPrivate+TextFields" + - MDFInternationalization + - "MaterialComponents/TextControls+Enums (109.8.0)" + - "MaterialComponents/TextControls+FilledTextAreas (109.8.0)": + - MaterialComponents/Availability + - "MaterialComponents/private/TextControlsPrivate+FilledStyle" + - "MaterialComponents/TextControls+BaseTextAreas" + - "MaterialComponents/TextControls+FilledTextAreasTheming (109.8.0)": + - MaterialComponents/schemes/Container + - "MaterialComponents/TextControls+FilledTextAreas" + - "MaterialComponents/TextControls+FilledTextFields (109.8.0)": + - MaterialComponents/Availability + - "MaterialComponents/private/TextControlsPrivate+FilledStyle" + - "MaterialComponents/TextControls+BaseTextFields" + - "MaterialComponents/TextControls+FilledTextFieldsTheming (109.8.0)": + - MaterialComponents/schemes/Container + - "MaterialComponents/TextControls+FilledTextFields" + - "MaterialComponents/TextControls+OutlinedTextAreas (109.8.0)": + - MaterialComponents/Availability + - "MaterialComponents/private/TextControlsPrivate+OutlinedStyle" + - "MaterialComponents/TextControls+BaseTextAreas" + - "MaterialComponents/TextControls+OutlinedTextAreasTheming (109.8.0)": + - MaterialComponents/schemes/Container + - "MaterialComponents/TextControls+OutlinedTextAreas" + - "MaterialComponents/TextControls+OutlinedTextFields (109.8.0)": + - MaterialComponents/Availability + - "MaterialComponents/private/TextControlsPrivate+OutlinedStyle" + - "MaterialComponents/TextControls+BaseTextFields" + - "MaterialComponents/TextControls+OutlinedTextFieldsTheming (109.8.0)": + - MaterialComponents/schemes/Container + - "MaterialComponents/TextControls+OutlinedTextFields" + - "MaterialComponents/TextControls+UnderlinedTextFields (109.8.0)": + - MaterialComponents/Availability + - "MaterialComponents/private/TextControlsPrivate+UnderlinedStyle" + - "MaterialComponents/TextControls+BaseTextFields" + - "MaterialComponents/TextControls+UnderlinedTextFieldsTheming (109.8.0)": + - MaterialComponents/schemes/Container + - "MaterialComponents/TextControls+UnderlinedTextFields" + - MaterialComponents/TextFields (109.8.0): + - MaterialComponents/AnimationTiming + - MaterialComponents/Buttons + - MaterialComponents/Elevation + - MaterialComponents/Palettes + - MaterialComponents/private/Math + - MaterialComponents/Typography + - MDFInternationalization + - "MaterialComponents/TextFields+ColorThemer (109.8.0)": + - MaterialComponents/TextFields + - MaterialComponents/Themes + - "MaterialComponents/TextFields+Theming (109.8.0)": + - MaterialComponents/schemes/Container + - MaterialComponents/TextFields + - "MaterialComponents/TextFields+ColorThemer" + - MaterialComponents/Themes (109.8.0): + - MaterialComponents/schemes/Color + - MaterialComponents/schemes/Typography + - MaterialComponents/Typography (109.8.0): + - MaterialComponents/private/Application + - MaterialComponents/private/Math + - MDFInternationalization (2.0.0) + - MDFTextAccessibility (2.0.0) + - Mocker (1.0.0) + - MotionAnimator (2.8.1): + - MotionInterchange (~> 1.6) + - MotionInterchange (1.6.0) + - MXParallaxHeader (1.1.0) + - Nimble (8.1.2) + - Pageboy (3.6.1) + - PureLayout (3.1.6) + - RxAlamofire (5.5.0): + - RxAlamofire/Core (= 5.5.0) + - RxAlamofire/Core (5.5.0): + - Alamofire (~> 5.1) + - RxSwift (~> 5.1) + - RxBlocking (5.1.1): + - RxSwift (~> 5) + - RxCocoa (5.1.1): + - RxRelay (~> 5) + - RxSwift (~> 5) + - RxDataSources (4.0.1): + - Differentiator (~> 4.0) + - RxCocoa (~> 5.0) + - RxSwift (~> 5.0) + - RxRelay (5.1.1): + - RxSwift (~> 5) + - RxSwift (5.1.1) + - RxTest (5.1.1): + - RxSwift (~> 5) + - SDWebImage (5.9.1): + - SDWebImage/Core (= 5.9.1) + - SDWebImage/Core (5.9.1) + - SwiftLint (0.39.2) + +DEPENDENCIES: + - Alamofire + - CocoaLumberjack/Swift + - CryptoSwift (= 1.1.2) + - MagazineLayout (from `https://github.com/farshadmb/MagazineLayout.git`) + - MaterialComponents + - Mocker (~> 1.0.0) + - MXParallaxHeader + - Nimble + - Pageboy + - PureLayout + - RxAlamofire + - RxBlocking + - RxCocoa + - RxDataSources + - RxSwift + - RxTest + - SDWebImage + - SwiftLint + +SPEC REPOS: + trunk: + - Alamofire + - CocoaLumberjack + - CryptoSwift + - Differentiator + - MaterialComponents + - MDFInternationalization + - MDFTextAccessibility + - Mocker + - MotionAnimator + - MotionInterchange + - MXParallaxHeader + - Nimble + - Pageboy + - PureLayout + - RxAlamofire + - RxBlocking + - RxCocoa + - RxDataSources + - RxRelay + - RxSwift + - RxTest + - SDWebImage + - SwiftLint + +EXTERNAL SOURCES: + MagazineLayout: + :git: https://github.com/farshadmb/MagazineLayout.git + +CHECKOUT OPTIONS: + MagazineLayout: + :commit: 1e795ecae0bb8f944a1c4c5f3bd92620c0ce0c93 + :git: https://github.com/farshadmb/MagazineLayout.git + +SPEC CHECKSUMS: + Alamofire: e911732990610fe89af59ac0077f923d72dc3dfd + CocoaLumberjack: b17ae15142558d08bbacf69775fa10c4abbebcc9 + CryptoSwift: 31dacd1f13427439ddae5b5cbaae4c8dbc43047e + Differentiator: 886080237d9f87f322641dedbc5be257061b0602 + MagazineLayout: 8e995730bc2b1ff8f11f44cb7d7926ab9640892f + MaterialComponents: 00df0652f52cd6968b02d531bd2e6956b0f907b8 + MDFInternationalization: 010097556d6b09d2c4ea38e0820ea6d37be6a314 + MDFTextAccessibility: 85c09a1bd9c321f494348e632a25063bcda35a53 + Mocker: 58560cc516f6240e92492dad66f295e7cdd7cdb2 + MotionAnimator: ee16aa30567c5bae0fb2750c132915829cfaaf8a + MotionInterchange: ead0e3ae1f3a5fb539e289debbc7ae036160a10d + MXParallaxHeader: de3c867e10ba46e8f6e20c8ee1f2a910372b3b94 + Nimble: 3864815b4703c7ebffba875973c70e854489fbae + Pageboy: 29a2d474ad99404b4d77f325e0ab6d705930a4fb + PureLayout: bd3c4ec3a3819ad387c99ebb72c6b129c3ed4d2d + RxAlamofire: 22287c710761466d0123504c566a8381520d4d63 + RxBlocking: 5f700a78cad61ce253ebd37c9a39b5ccc76477b4 + RxCocoa: 32065309a38d29b5b0db858819b5bf9ef038b601 + RxDataSources: efee07fa4de48477eca0a4611e6d11e2da9c1114 + RxRelay: d77f7d771495f43c556cbc43eebd1bb54d01e8e9 + RxSwift: 81470a2074fa8780320ea5fe4102807cb7118178 + RxTest: 711632d5644dffbeb62c936a521b5b008a1e1faa + SDWebImage: a990c053fff71e388a10f3357edb0be17929c9c5 + SwiftLint: 22ccbbe3b8008684be5955693bab135e0ed6a447 + +PODFILE CHECKSUM: cde5b5350234211e6adef69591befc23defa638c + +COCOAPODS: 1.9.3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..13ca289 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# DutchNewsApp + +The Application was written in a Swift 5.1. The App represent Headlines API and Authentication. Application contains the three layers which are the `Data Layer`, `Domain Layer` or `Business Layer` and `Presentation Layer` in MVVM. + + +## Requirements + +- Xcode 11.2 or laster. +- Swift 5.1 or later. +- iOS 11.0 or later. + +## Installation + +- Step 1: + Download or clone the project github repository + + ```Bash + $ git clone https://github.com/farshadmb/DutchNewsApp.git + ``` +- Step 2: + Install Bundler via Running the below command + ```Bash + gem install bundler + ``` + Install Cocoapods via + ```Bash + gem install cocoapods --user-install + ``` + + then run ``` $ pods install ``` command. + +- Step 3: + Open ```DutchNews.xcworspace``` file. + +- Step 4: + Run and enjoy the app. + +## Technology use in this project +* Reactive Functional Programming +* RxSwift +* Clean Code +* Clean Arch [Reference](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +* Modern MVVM +* Loclization +* CI Testing +* CollectionView Custom Layout + +## - Gitwokflow + +[See PRs](https://github.com/farshadmb/DutchNewsApp/pulls?q=is%3Apr+is%3Aclosed) Closed PRs + diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..1803063 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,6 @@ +# app_identifier("[[APP_IDENTIFIER]]") # The bundle identifier of your app +# apple_id("[[APPLE_ID]]") # Your Apple email address + + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..44dfc13 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,99 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +require 'json' + +default_platform(:ios) + +platform :ios do + + # Variables # + scheme = "DutchNews" + workspace = "#{scheme}.xcworkspace" + projectspace = "#{scheme}.xcodeproj" + + version = "" + + before_all do |lane| + + UI.message "prepare for builds" + begin + xcversion(version: "~> 11.6") + rescue + xcversion(version: "~> 11.2") + end + + version = get_version_number(xcodeproj:projectspace,target:scheme) + + + if lane != :run_ci_tests + #for cocoapods install dependecy + cocoapods() + end + + clear_derived_data() #clear all derived_data + + if lane != :run_ci_tests + enable_automatic_code_signing() #autosiging + end + + UI.message "prepared for build" + + end + + after_all do |lane| + notification(title:"Success",subtitle:"#{scheme} #{version}",message:lane.to_s,sound:"Default") + UI.message "Success Build #{scheme} #{version} in lane #{lane.to_s} " + end + + error do |lane, exception| + # Send error notification + notification(title:"Failure on #{scheme} #{version}",subtitle:lane.to_s,message:exception.to_s,sound:"Default") + raise "Failure on #{scheme} #{version} \n in lane #{lane.to_s} \n exception = #{exception.to_s}" + end + + desc "generate report after running tests" + def generate_report + puts "Generating Test Report ..." + sh "xchtmlreport -r ./fastlane/out_put/TestResults" + puts "Test Report Successfully generated" + end + + desc "Run App Unit tests on given devices name" + lane :run_ci_tests do |options| + + UI.message "The device raw : #{options[:device]}" + device = options[:device] + UI.message "The device list : #{device}" + + clean = false + + if options[:clean] then + clean = options[:clean] + end + + open_report = false + + if options[:open_report] then + open_report = options[:open_report] + end + + scan(clean: clean, # clean project folder before test execution + device: device, # Devices for testing + configuration: "Debug", + open_report: open_report) + end + +end diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 0000000..9cbfa72 --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,5 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-xchtmlreport' diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 0000000..8edd373 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,31 @@ +fastlane documentation +================ +# Installation + +Make sure you have the latest version of the Xcode command line tools installed: + +``` +xcode-select --install +``` + +Install _fastlane_ using +``` +[sudo] gem install fastlane -NV +``` +or alternatively using `brew install fastlane` + +# Available Actions +## iOS +### ios run_ci_tests +``` +fastlane ios run_ci_tests +``` +generate report after running tests + +Run App Unit tests on given devices name + +---- + +This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run. +More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). +The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools).