diff --git a/.github/images/screenshot.png b/.github/images/screenshot.png new file mode 100644 index 0000000..d209c5f Binary files /dev/null and b/.github/images/screenshot.png differ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..57bc3a0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,21 @@ +name: Run tests + +on: pull_request + +jobs: + tests: + name: Tests + runs-on: macOS-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Select Xcode 11 + run: sudo xcode-select -switch /Applications/Xcode_11.7.app + + - name: Install bundle + run: bundle install + + - name: Build and Test + run: bundle exec fastlane ios tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4c2a18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace +Pods/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +coverage diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..7d45740 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,6 @@ +disabled_rules: + - type_name + - identifier_name + +excluded: + - Pods diff --git a/Chuck Norris Facts.xcodeproj/project.pbxproj b/Chuck Norris Facts.xcodeproj/project.pbxproj index ee03216..e64b70e 100644 --- a/Chuck Norris Facts.xcodeproj/project.pbxproj +++ b/Chuck Norris Facts.xcodeproj/project.pbxproj @@ -3,18 +3,93 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 51; objects = { /* Begin PBXBuildFile section */ + 1E049E13254F5A5300226E0B /* RxSwift+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E049E12254F5A5300226E0B /* RxSwift+Extensions.swift */; }; + 1E135FAA254B4E66009D18AF /* LaunchArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E135FA9254B4E66009D18AF /* LaunchArgument.swift */; }; + 1E135FAD254B4E9F009D18AF /* XCUIApplication+LaunchArgument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E135FAC254B4E9F009D18AF /* XCUIApplication+LaunchArgument.swift */; }; + 1E135FAF254B52E0009D18AF /* facts.json in Resources */ = {isa = PBXBuildFile; fileRef = 1E135FAE254B52E0009D18AF /* facts.json */; }; + 1E23683E253FB07200BE17F3 /* FactCategoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E23683D253FB07200BE17F3 /* FactCategoryCell.swift */; }; + 1E236840253FB13A00BE17F3 /* FactCategoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E23683F253FB13A00BE17F3 /* FactCategoryViewModel.swift */; }; + 1E3075C2254C9D0B0082A194 /* APITarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3075C1254C9D0B0082A194 /* APITarget.swift */; }; + 1E3075C5254C9D710082A194 /* HTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3075C4254C9D710082A194 /* HTTPMethod.swift */; }; + 1E3075C9254CA5F70082A194 /* APIProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3075C8254CA5F70082A194 /* APIProvider.swift */; }; + 1E32758F2532A2C0007E838A /* EmptyListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E32758E2532A2C0007E838A /* EmptyListView.swift */; }; + 1E3275922532A2CD007E838A /* empty-box.json in Resources */ = {isa = PBXBuildFile; fileRef = 1E3275912532A2CD007E838A /* empty-box.json */; }; + 1E463803253636160079D8E9 /* SearchFactsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E463802253636160079D8E9 /* SearchFactsViewController.swift */; }; + 1E463805253636D80079D8E9 /* SearchFactsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E463804253636D80079D8E9 /* SearchFactsCoordinator.swift */; }; + 1E46380B25363FF40079D8E9 /* SearchFactsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E46380A25363FF40079D8E9 /* SearchFactsViewModel.swift */; }; + 1E5617242540F43F00BF26A0 /* FactsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E5617232540F43F00BF26A0 /* FactsServiceTests.swift */; }; + 1E5617282540FAF200BF26A0 /* get-categories.json in Resources */ = {isa = PBXBuildFile; fileRef = 1E5617272540FAF200BF26A0 /* get-categories.json */; }; + 1E56172C2541007500BF26A0 /* Data+Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E56172B2541007500BF26A0 /* Data+Stub.swift */; }; + 1E56172E2541039B00BF26A0 /* SearchFactsViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E56172D2541039B00BF26A0 /* SearchFactsViewControllerTests.swift */; }; + 1E580BC8254B92E600886A2E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1E580BC7254B92E600886A2E /* Localizable.strings */; }; + 1E5A87C4254CD8E30039DE07 /* search-facts.json in Resources */ = {isa = PBXBuildFile; fileRef = 1E5A87C3254CD8E30039DE07 /* search-facts.json */; }; + 1E655786254CB0FF00950706 /* APIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E655785254CB0FF00950706 /* APIError.swift */; }; + 1E655788254CB13B00950706 /* APIResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E655787254CB13B00950706 /* APIResponse.swift */; }; + 1E65578C254CB20D00950706 /* API+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E65578B254CB20D00950706 /* API+Rx.swift */; }; + 1E65578E254CB22800950706 /* URLRequest+Encoded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E65578D254CB22800950706 /* URLRequest+Encoded.swift */; }; + 1E6D568F25505D5700D27284 /* FactsListErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E6D568E25505D5700D27284 /* FactsListErrorViewModel.swift */; }; + 1E7A6528254DA2B1006E493B /* HTTPTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7A6527254DA2B1006E493B /* HTTPTask.swift */; }; + 1E7F15BE253324CF0006887B /* FactsListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7F15BD253324CF0006887B /* FactsListViewModelTests.swift */; }; + 1E7F15C0253324FD0006887B /* FactsListViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7F15BF253324FD0006887B /* FactsListViewControllerTests.swift */; }; + 1E7F15C6253329780006887B /* FactViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7F15C5253329780006887B /* FactViewModelTests.swift */; }; + 1E7F9F9B254CD5110062613C /* APIMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E7F9F9A254CD5110062613C /* APIMock.swift */; }; + 1E8A0FEC25475B0800565A86 /* SearchFactsTableViewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8A0FEB25475B0800565A86 /* SearchFactsTableViewSection.swift */; }; + 1E8A0FEE2547603700565A86 /* SuggestionsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8A0FED2547603700565A86 /* SuggestionsCell.swift */; }; + 1E8A0FF0254760D400565A86 /* SuggestionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8A0FEF254760D400565A86 /* SuggestionsViewModel.swift */; }; + 1E8A0FF42547768500565A86 /* DynamicHeightCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8A0FF32547768500565A86 /* DynamicHeightCollectionView.swift */; }; + 1E8AF33A254793D800BBB808 /* PastSearchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AF339254793D800BBB808 /* PastSearchCell.swift */; }; + 1E92112A253F6BB700DB340B /* SearchFactsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E921129253F6BB700DB340B /* SearchFactsResponse.swift */; }; + 1E92112D253F6D0000DB340B /* FactsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E92112C253F6D0000DB340B /* FactsService.swift */; }; + 1E92112F253F7A0B00DB340B /* SearchFactsScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E92112E253F7A0B00DB340B /* SearchFactsScene.swift */; }; + 1E921131253F7AAA00DB340B /* SearchFactsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E921130253F7AAA00DB340B /* SearchFactsUITests.swift */; }; + 1E921134253F84F100DB340B /* SearchFactsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E921133253F84F100DB340B /* SearchFactsViewModelTests.swift */; }; + 1E921139253F909700DB340B /* FactsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E921138253F909700DB340B /* FactsStorage.swift */; }; + 1E92113B253F90BF00DB340B /* FactCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E92113A253F90BF00DB340B /* FactCategory.swift */; }; + 1E92113E253F915100DB340B /* FactCategoryEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E92113D253F915100DB340B /* FactCategoryEntity.swift */; }; + 1E9489F3254DEB2500A0002C /* FactCategoryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9489F2254DEB2500A0002C /* FactCategoryViewModelTests.swift */; }; + 1E9489F5254DEBB200A0002C /* fact-category.json in Resources */ = {isa = PBXBuildFile; fileRef = 1E9489F4254DEBB200A0002C /* fact-category.json */; }; + 1EA3AB02254B956C004A877B /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EA3AB01254B956C004A877B /* Strings.swift */; }; + 1EAB20AF2540BEC400633382 /* SuggestionsViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAB20AE2540BEC400633382 /* SuggestionsViewFlowLayout.swift */; }; + 1EACEC99253649BD0006B36D /* loading.json in Resources */ = {isa = PBXBuildFile; fileRef = 1EACEC98253649BD0006B36D /* loading.json */; }; + 1EACEC9E25364B6C0006B36D /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EACEC9D25364B6C0006B36D /* ActivityIndicator.swift */; }; + 1ED06C952548AAD300139151 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED06C942548AAD300139151 /* LoadingView.swift */; }; + 1ED5D18D25348FD50035046C /* XCTestCase+Stub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED5D18C25348FD50035046C /* XCTestCase+Stub.swift */; }; + 1ED5D19025349E9D0035046C /* UIViewController+Rx.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED5D18F25349E9D0035046C /* UIViewController+Rx.swift */; }; + 1ED5D1932534A56F0035046C /* FactsServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED5D1922534A56F0035046C /* FactsServiceMock.swift */; }; + 1ED5D1982534AA700035046C /* long-fact.json in Resources */ = {isa = PBXBuildFile; fileRef = 1ED5D1972534AA700035046C /* long-fact.json */; }; + 1ED5D19A2534AA7A0035046C /* facts-list.json in Resources */ = {isa = PBXBuildFile; fileRef = 1ED5D1992534AA7A0035046C /* facts-list.json */; }; + 1ED5D19C2534AAE40035046C /* short-fact.json in Resources */ = {isa = PBXBuildFile; fileRef = 1ED5D19B2534AAE40035046C /* short-fact.json */; }; + 1ED5D19F2534B0E30035046C /* FactsListScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED5D19E2534B0E30035046C /* FactsListScene.swift */; }; + 1ED5D1A22534B0F40035046C /* FactsListUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED5D1A12534B0F40035046C /* FactsListUITests.swift */; }; + 1EDF0B372541C851001931AA /* get-categories.json in Resources */ = {isa = PBXBuildFile; fileRef = 1EDF0B362541C851001931AA /* get-categories.json */; }; 1EE0713D25314AF500F6BF6D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE0713C25314AF500F6BF6D /* AppDelegate.swift */; }; 1EE0713F25314AF500F6BF6D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE0713E25314AF500F6BF6D /* SceneDelegate.swift */; }; - 1EE0714125314AF500F6BF6D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE0714025314AF500F6BF6D /* ViewController.swift */; }; - 1EE0714425314AF500F6BF6D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1EE0714225314AF500F6BF6D /* Main.storyboard */; }; 1EE0714625314AF600F6BF6D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1EE0714525314AF600F6BF6D /* Assets.xcassets */; }; 1EE0714925314AF600F6BF6D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1EE0714725314AF600F6BF6D /* LaunchScreen.storyboard */; }; - 1EE0715425314AF600F6BF6D /* Chuck_Norris_FactsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE0715325314AF600F6BF6D /* Chuck_Norris_FactsTests.swift */; }; - 1EE0715F25314AF600F6BF6D /* Chuck_Norris_FactsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EE0715E25314AF600F6BF6D /* Chuck_Norris_FactsUITests.swift */; }; + 1EEDC69F254A301D00D75F3E /* UITableView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EEDC69E254A301D00D75F3E /* UITableView+Extensions.swift */; }; + 1EEDC6A1254A331500D75F3E /* UICollectionView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EEDC6A0254A331500D75F3E /* UICollectionView+Extensions.swift */; }; + 1EEDC6A5254A408B00D75F3E /* FactsListError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EEDC6A4254A408B00D75F3E /* FactsListError.swift */; }; + 1EF066E32545CE1D00ECF611 /* PastSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF066E22545CE1D00ECF611 /* PastSearchViewModel.swift */; }; + 1EF066E52545CEC200ECF611 /* SearchEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF066E42545CEC200ECF611 /* SearchEntity.swift */; }; + 1EF0DA1425449898005CF7E2 /* CategoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF0DA1325449898005CF7E2 /* CategoryView.swift */; }; + 1EFE2867253206D3008806B9 /* BaseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE2866253206D3008806B9 /* BaseCoordinator.swift */; }; + 1EFE2869253207B2008806B9 /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE2868253207B2008806B9 /* AppCoordinator.swift */; }; + 1EFE287E25321071008806B9 /* search-facts.json in Resources */ = {isa = PBXBuildFile; fileRef = 1EFE287D25321071008806B9 /* search-facts.json */; }; + 1EFE2884253210B2008806B9 /* FactsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE2883253210B2008806B9 /* FactsAPI.swift */; }; + 1EFE288725321119008806B9 /* Fact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE288625321119008806B9 /* Fact.swift */; }; + 1EFE288925321123008806B9 /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE288825321123008806B9 /* JSON.swift */; }; + 1EFE288E2532135B008806B9 /* FactsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE288D2532135B008806B9 /* FactsListViewController.swift */; }; + 1EFE28902532137C008806B9 /* FactsListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE288F2532137C008806B9 /* FactsListCoordinator.swift */; }; + 1EFE2892253214DB008806B9 /* FactsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE2891253214DB008806B9 /* FactsListViewModel.swift */; }; + 1EFE289525321CD2008806B9 /* FactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE289425321CD2008806B9 /* FactViewModel.swift */; }; + 1EFE289725321CE2008806B9 /* FactCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFE289625321CE2008806B9 /* FactCell.swift */; }; + 54AACBFAFA5E5798DDE49218 /* Pods_Chuck_Norris_Facts.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02186229E02F0A408D2EE0C2 /* Pods_Chuck_Norris_Facts.framework */; }; + D5197BBE6E28E81FC84E5C48 /* Pods_Chuck_Norris_FactsTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF9A8C577644798596588861 /* Pods_Chuck_Norris_FactsTests.framework */; }; + E08677EA93CEAFF961473756 /* Pods_Chuck_Norris_Facts_Chuck_Norris_FactsUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7C1F7C697FAAC85D6A1F91F4 /* Pods_Chuck_Norris_Facts_Chuck_Norris_FactsUITests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -35,20 +110,101 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 02186229E02F0A408D2EE0C2 /* Pods_Chuck_Norris_Facts.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Chuck_Norris_Facts.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1E049E12254F5A5300226E0B /* RxSwift+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RxSwift+Extensions.swift"; sourceTree = ""; }; + 1E135FA9254B4E66009D18AF /* LaunchArgument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchArgument.swift; sourceTree = ""; }; + 1E135FAC254B4E9F009D18AF /* XCUIApplication+LaunchArgument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+LaunchArgument.swift"; sourceTree = ""; }; + 1E135FAE254B52E0009D18AF /* facts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = facts.json; sourceTree = ""; }; + 1E23683D253FB07200BE17F3 /* FactCategoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactCategoryCell.swift; sourceTree = ""; }; + 1E23683F253FB13A00BE17F3 /* FactCategoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactCategoryViewModel.swift; sourceTree = ""; }; + 1E3075C1254C9D0B0082A194 /* APITarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APITarget.swift; sourceTree = ""; }; + 1E3075C4254C9D710082A194 /* HTTPMethod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPMethod.swift; sourceTree = ""; }; + 1E3075C8254CA5F70082A194 /* APIProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIProvider.swift; sourceTree = ""; }; + 1E32758E2532A2C0007E838A /* EmptyListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyListView.swift; sourceTree = ""; }; + 1E3275912532A2CD007E838A /* empty-box.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "empty-box.json"; sourceTree = ""; }; + 1E463802253636160079D8E9 /* SearchFactsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFactsViewController.swift; sourceTree = ""; }; + 1E463804253636D80079D8E9 /* SearchFactsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFactsCoordinator.swift; sourceTree = ""; }; + 1E46380A25363FF40079D8E9 /* SearchFactsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFactsViewModel.swift; sourceTree = ""; }; + 1E5617232540F43F00BF26A0 /* FactsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsServiceTests.swift; sourceTree = ""; }; + 1E5617272540FAF200BF26A0 /* get-categories.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "get-categories.json"; sourceTree = ""; }; + 1E56172B2541007500BF26A0 /* Data+Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Stub.swift"; sourceTree = ""; }; + 1E56172D2541039B00BF26A0 /* SearchFactsViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFactsViewControllerTests.swift; sourceTree = ""; }; + 1E580BC7254B92E600886A2E /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; + 1E5A87C3254CD8E30039DE07 /* search-facts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "search-facts.json"; sourceTree = ""; }; + 1E655785254CB0FF00950706 /* APIError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIError.swift; sourceTree = ""; }; + 1E655787254CB13B00950706 /* APIResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIResponse.swift; sourceTree = ""; }; + 1E65578B254CB20D00950706 /* API+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "API+Rx.swift"; sourceTree = ""; }; + 1E65578D254CB22800950706 /* URLRequest+Encoded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+Encoded.swift"; sourceTree = ""; }; + 1E6D568E25505D5700D27284 /* FactsListErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsListErrorViewModel.swift; sourceTree = ""; }; + 1E7A6527254DA2B1006E493B /* HTTPTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTask.swift; sourceTree = ""; }; + 1E7F15BD253324CF0006887B /* FactsListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsListViewModelTests.swift; sourceTree = ""; }; + 1E7F15BF253324FD0006887B /* FactsListViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsListViewControllerTests.swift; sourceTree = ""; }; + 1E7F15C5253329780006887B /* FactViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactViewModelTests.swift; sourceTree = ""; }; + 1E7F9F9A254CD5110062613C /* APIMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIMock.swift; sourceTree = ""; }; + 1E8A0FEB25475B0800565A86 /* SearchFactsTableViewSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFactsTableViewSection.swift; sourceTree = ""; }; + 1E8A0FED2547603700565A86 /* SuggestionsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionsCell.swift; sourceTree = ""; }; + 1E8A0FEF254760D400565A86 /* SuggestionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionsViewModel.swift; sourceTree = ""; }; + 1E8A0FF32547768500565A86 /* DynamicHeightCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicHeightCollectionView.swift; sourceTree = ""; }; + 1E8AF339254793D800BBB808 /* PastSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PastSearchCell.swift; sourceTree = ""; }; + 1E921129253F6BB700DB340B /* SearchFactsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFactsResponse.swift; sourceTree = ""; }; + 1E92112C253F6D0000DB340B /* FactsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsService.swift; sourceTree = ""; }; + 1E92112E253F7A0B00DB340B /* SearchFactsScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFactsScene.swift; sourceTree = ""; }; + 1E921130253F7AAA00DB340B /* SearchFactsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFactsUITests.swift; sourceTree = ""; }; + 1E921133253F84F100DB340B /* SearchFactsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFactsViewModelTests.swift; sourceTree = ""; }; + 1E921138253F909700DB340B /* FactsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsStorage.swift; sourceTree = ""; }; + 1E92113A253F90BF00DB340B /* FactCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactCategory.swift; sourceTree = ""; }; + 1E92113D253F915100DB340B /* FactCategoryEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactCategoryEntity.swift; sourceTree = ""; }; + 1E9489F2254DEB2500A0002C /* FactCategoryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactCategoryViewModelTests.swift; sourceTree = ""; }; + 1E9489F4254DEBB200A0002C /* fact-category.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "fact-category.json"; sourceTree = ""; }; + 1EA3AB01254B956C004A877B /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; + 1EAB20AE2540BEC400633382 /* SuggestionsViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionsViewFlowLayout.swift; sourceTree = ""; }; + 1EACEC98253649BD0006B36D /* loading.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = loading.json; sourceTree = ""; }; + 1EACEC9D25364B6C0006B36D /* ActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; + 1ED06C942548AAD300139151 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + 1ED5D18C25348FD50035046C /* XCTestCase+Stub.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+Stub.swift"; sourceTree = ""; }; + 1ED5D18F25349E9D0035046C /* UIViewController+Rx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Rx.swift"; sourceTree = ""; }; + 1ED5D1922534A56F0035046C /* FactsServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsServiceMock.swift; sourceTree = ""; }; + 1ED5D1972534AA700035046C /* long-fact.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "long-fact.json"; sourceTree = ""; }; + 1ED5D1992534AA7A0035046C /* facts-list.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "facts-list.json"; sourceTree = ""; }; + 1ED5D19B2534AAE40035046C /* short-fact.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "short-fact.json"; sourceTree = ""; }; + 1ED5D19E2534B0E30035046C /* FactsListScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsListScene.swift; sourceTree = ""; }; + 1ED5D1A12534B0F40035046C /* FactsListUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsListUITests.swift; sourceTree = ""; }; + 1EDF0B362541C851001931AA /* get-categories.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "get-categories.json"; sourceTree = ""; }; 1EE0713925314AF500F6BF6D /* Chuck Norris Facts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chuck Norris Facts.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 1EE0713C25314AF500F6BF6D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 1EE0713E25314AF500F6BF6D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 1EE0714025314AF500F6BF6D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; - 1EE0714325314AF500F6BF6D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 1EE0714525314AF600F6BF6D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 1EE0714825314AF600F6BF6D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 1EE0714A25314AF600F6BF6D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1EE0714F25314AF600F6BF6D /* Chuck Norris FactsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Chuck Norris FactsTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 1EE0715325314AF600F6BF6D /* Chuck_Norris_FactsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chuck_Norris_FactsTests.swift; sourceTree = ""; }; 1EE0715525314AF600F6BF6D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1EE0715A25314AF600F6BF6D /* Chuck Norris FactsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Chuck Norris FactsUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 1EE0715E25314AF600F6BF6D /* Chuck_Norris_FactsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chuck_Norris_FactsUITests.swift; sourceTree = ""; }; 1EE0716025314AF600F6BF6D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1EEDC69E254A301D00D75F3E /* UITableView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableView+Extensions.swift"; sourceTree = ""; }; + 1EEDC6A0254A331500D75F3E /* UICollectionView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UICollectionView+Extensions.swift"; sourceTree = ""; }; + 1EEDC6A4254A408B00D75F3E /* FactsListError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsListError.swift; sourceTree = ""; }; + 1EF066E22545CE1D00ECF611 /* PastSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PastSearchViewModel.swift; sourceTree = ""; }; + 1EF066E42545CEC200ECF611 /* SearchEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchEntity.swift; sourceTree = ""; }; + 1EF0DA1325449898005CF7E2 /* CategoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryView.swift; sourceTree = ""; }; + 1EFE2866253206D3008806B9 /* BaseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCoordinator.swift; sourceTree = ""; }; + 1EFE2868253207B2008806B9 /* AppCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + 1EFE287D25321071008806B9 /* search-facts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "search-facts.json"; sourceTree = ""; }; + 1EFE2883253210B2008806B9 /* FactsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsAPI.swift; sourceTree = ""; }; + 1EFE288625321119008806B9 /* Fact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fact.swift; sourceTree = ""; }; + 1EFE288825321123008806B9 /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; + 1EFE288D2532135B008806B9 /* FactsListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsListViewController.swift; sourceTree = ""; }; + 1EFE288F2532137C008806B9 /* FactsListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsListCoordinator.swift; sourceTree = ""; }; + 1EFE2891253214DB008806B9 /* FactsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactsListViewModel.swift; sourceTree = ""; }; + 1EFE289425321CD2008806B9 /* FactViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactViewModel.swift; sourceTree = ""; }; + 1EFE289625321CE2008806B9 /* FactCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactCell.swift; sourceTree = ""; }; + 21EE31E3903E76B09B8FBA96 /* Pods-Chuck Norris Facts-Chuck Norris FactsUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chuck Norris Facts-Chuck Norris FactsUITests.debug.xcconfig"; path = "Target Support Files/Pods-Chuck Norris Facts-Chuck Norris FactsUITests/Pods-Chuck Norris Facts-Chuck Norris FactsUITests.debug.xcconfig"; sourceTree = ""; }; + 5BFEC696AC24CC9BF87C5195 /* Pods-Chuck Norris FactsTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chuck Norris FactsTests.release.xcconfig"; path = "Target Support Files/Pods-Chuck Norris FactsTests/Pods-Chuck Norris FactsTests.release.xcconfig"; sourceTree = ""; }; + 74304E6C0D317767335DC2AB /* Pods-Chuck Norris Facts.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chuck Norris Facts.release.xcconfig"; path = "Target Support Files/Pods-Chuck Norris Facts/Pods-Chuck Norris Facts.release.xcconfig"; sourceTree = ""; }; + 7C1F7C697FAAC85D6A1F91F4 /* Pods_Chuck_Norris_Facts_Chuck_Norris_FactsUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Chuck_Norris_Facts_Chuck_Norris_FactsUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BA72CEAC53E67FEFA608F6B0 /* Pods-Chuck Norris Facts-Chuck Norris FactsUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chuck Norris Facts-Chuck Norris FactsUITests.release.xcconfig"; path = "Target Support Files/Pods-Chuck Norris Facts-Chuck Norris FactsUITests/Pods-Chuck Norris Facts-Chuck Norris FactsUITests.release.xcconfig"; sourceTree = ""; }; + BBB43EE571174B99828001E3 /* Pods-Chuck Norris Facts.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chuck Norris Facts.debug.xcconfig"; path = "Target Support Files/Pods-Chuck Norris Facts/Pods-Chuck Norris Facts.debug.xcconfig"; sourceTree = ""; }; + EA8CB0ABD6CE41AE1F636AB6 /* Pods-Chuck Norris FactsTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Chuck Norris FactsTests.debug.xcconfig"; path = "Target Support Files/Pods-Chuck Norris FactsTests/Pods-Chuck Norris FactsTests.debug.xcconfig"; sourceTree = ""; }; + EF9A8C577644798596588861 /* Pods_Chuck_Norris_FactsTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Chuck_Norris_FactsTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -56,6 +212,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 54AACBFAFA5E5798DDE49218 /* Pods_Chuck_Norris_Facts.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -63,6 +220,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D5197BBE6E28E81FC84E5C48 /* Pods_Chuck_Norris_FactsTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -70,12 +228,311 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E08677EA93CEAFF961473756 /* Pods_Chuck_Norris_Facts_Chuck_Norris_FactsUITests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1E135FAB254B4E92009D18AF /* Library */ = { + isa = PBXGroup; + children = ( + 1E135FAC254B4E9F009D18AF /* XCUIApplication+LaunchArgument.swift */, + ); + path = Library; + sourceTree = ""; + }; + 1E23683C253FB05100BE17F3 /* PastSearch */ = { + isa = PBXGroup; + children = ( + 1E8AF339254793D800BBB808 /* PastSearchCell.swift */, + 1EF066E22545CE1D00ECF611 /* PastSearchViewModel.swift */, + ); + path = PastSearch; + sourceTree = ""; + }; + 1E3075C0254C9CFC0082A194 /* API */ = { + isa = PBXGroup; + children = ( + 1E3075C3254C9D680082A194 /* HTTP */, + 1E3075C1254C9D0B0082A194 /* APITarget.swift */, + 1E3075C8254CA5F70082A194 /* APIProvider.swift */, + 1E655785254CB0FF00950706 /* APIError.swift */, + 1E655787254CB13B00950706 /* APIResponse.swift */, + ); + path = API; + sourceTree = ""; + }; + 1E3075C3254C9D680082A194 /* HTTP */ = { + isa = PBXGroup; + children = ( + 1E3075C4254C9D710082A194 /* HTTPMethod.swift */, + 1E7A6527254DA2B1006E493B /* HTTPTask.swift */, + ); + path = HTTP; + sourceTree = ""; + }; + 1E32758D2532A2A3007E838A /* Views */ = { + isa = PBXGroup; + children = ( + 1E32758E2532A2C0007E838A /* EmptyListView.swift */, + 1ED06C942548AAD300139151 /* LoadingView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 1E3275902532A2C4007E838A /* Animations */ = { + isa = PBXGroup; + children = ( + 1EACEC98253649BD0006B36D /* loading.json */, + 1E3275912532A2CD007E838A /* empty-box.json */, + ); + path = Animations; + sourceTree = ""; + }; + 1E463801253636050079D8E9 /* SearchFacts */ = { + isa = PBXGroup; + children = ( + 1E8AF338254792E500BBB808 /* Suggestions */, + 1E23683C253FB05100BE17F3 /* PastSearch */, + 1E463802253636160079D8E9 /* SearchFactsViewController.swift */, + 1E463804253636D80079D8E9 /* SearchFactsCoordinator.swift */, + 1E46380A25363FF40079D8E9 /* SearchFactsViewModel.swift */, + 1E8A0FEB25475B0800565A86 /* SearchFactsTableViewSection.swift */, + ); + path = SearchFacts; + sourceTree = ""; + }; + 1E49C420254F8FC3005BB6B4 /* Views */ = { + isa = PBXGroup; + children = ( + 1EF0DA1325449898005CF7E2 /* CategoryView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 1E49C421254F9043005BB6B4 /* Core */ = { + isa = PBXGroup; + children = ( + 1E3075C0254C9CFC0082A194 /* API */, + 1ED5D18E25349E8D0035046C /* Extensions */, + 1EFE2881253210A4008806B9 /* Data */, + 1EFE2865253206C8008806B9 /* Library */, + ); + path = Core; + sourceTree = ""; + }; + 1E5617212540F42600BF26A0 /* Data */ = { + isa = PBXGroup; + children = ( + 1E5617222540F42E00BF26A0 /* Services */, + ); + path = Data; + sourceTree = ""; + }; + 1E5617222540F42E00BF26A0 /* Services */ = { + isa = PBXGroup; + children = ( + 1E5617232540F43F00BF26A0 /* FactsServiceTests.swift */, + ); + path = Services; + sourceTree = ""; + }; + 1E6D568D25505D3F00D27284 /* Error */ = { + isa = PBXGroup; + children = ( + 1EEDC6A4254A408B00D75F3E /* FactsListError.swift */, + 1E6D568E25505D5700D27284 /* FactsListErrorViewModel.swift */, + ); + path = Error; + sourceTree = ""; + }; + 1E7F15BA253324760006887B /* Scenes */ = { + isa = PBXGroup; + children = ( + 1E7F15BB253324B10006887B /* Facts */, + ); + path = Scenes; + sourceTree = ""; + }; + 1E7F15BB253324B10006887B /* Facts */ = { + isa = PBXGroup; + children = ( + 1E921132253F84DE00DB340B /* SearchFacts */, + 1E7F15BC253324BD0006887B /* FactsList */, + ); + path = Facts; + sourceTree = ""; + }; + 1E7F15BC253324BD0006887B /* FactsList */ = { + isa = PBXGroup; + children = ( + 1E7F15C4253329600006887B /* Fact */, + 1E7F15BD253324CF0006887B /* FactsListViewModelTests.swift */, + 1E7F15BF253324FD0006887B /* FactsListViewControllerTests.swift */, + ); + path = FactsList; + sourceTree = ""; + }; + 1E7F15C4253329600006887B /* Fact */ = { + isa = PBXGroup; + children = ( + 1E7F15C5253329780006887B /* FactViewModelTests.swift */, + ); + path = Fact; + sourceTree = ""; + }; + 1E8AF337254792DB00BBB808 /* FactCategory */ = { + isa = PBXGroup; + children = ( + 1E23683D253FB07200BE17F3 /* FactCategoryCell.swift */, + 1E23683F253FB13A00BE17F3 /* FactCategoryViewModel.swift */, + ); + path = FactCategory; + sourceTree = ""; + }; + 1E8AF338254792E500BBB808 /* Suggestions */ = { + isa = PBXGroup; + children = ( + 1E8A0FED2547603700565A86 /* SuggestionsCell.swift */, + 1E8A0FEF254760D400565A86 /* SuggestionsViewModel.swift */, + 1EAB20AE2540BEC400633382 /* SuggestionsViewFlowLayout.swift */, + 1E8AF337254792DB00BBB808 /* FactCategory */, + ); + path = Suggestions; + sourceTree = ""; + }; + 1E921128253F6BA500DB340B /* Responses */ = { + isa = PBXGroup; + children = ( + 1E921129253F6BB700DB340B /* SearchFactsResponse.swift */, + ); + path = Responses; + sourceTree = ""; + }; + 1E92112B253F6CF500DB340B /* Services */ = { + isa = PBXGroup; + children = ( + 1E92112C253F6D0000DB340B /* FactsService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 1E921132253F84DE00DB340B /* SearchFacts */ = { + isa = PBXGroup; + children = ( + 1E9489F0254DEB0B00A0002C /* Suggestions */, + 1E921133253F84F100DB340B /* SearchFactsViewModelTests.swift */, + 1E56172D2541039B00BF26A0 /* SearchFactsViewControllerTests.swift */, + ); + path = SearchFacts; + sourceTree = ""; + }; + 1E921137253F908C00DB340B /* Storage */ = { + isa = PBXGroup; + children = ( + 1E92113C253F914400DB340B /* Entities */, + 1E921138253F909700DB340B /* FactsStorage.swift */, + ); + path = Storage; + sourceTree = ""; + }; + 1E92113C253F914400DB340B /* Entities */ = { + isa = PBXGroup; + children = ( + 1E92113D253F915100DB340B /* FactCategoryEntity.swift */, + 1EF066E42545CEC200ECF611 /* SearchEntity.swift */, + ); + path = Entities; + sourceTree = ""; + }; + 1E9489F0254DEB0B00A0002C /* Suggestions */ = { + isa = PBXGroup; + children = ( + 1E9489F1254DEB1300A0002C /* FactCategory */, + ); + path = Suggestions; + sourceTree = ""; + }; + 1E9489F1254DEB1300A0002C /* FactCategory */ = { + isa = PBXGroup; + children = ( + 1E9489F2254DEB2500A0002C /* FactCategoryViewModelTests.swift */, + ); + path = FactCategory; + sourceTree = ""; + }; + 1EA3AB00254B955F004A877B /* Generated */ = { + isa = PBXGroup; + children = ( + 1EA3AB01254B956C004A877B /* Strings.swift */, + ); + path = Generated; + sourceTree = ""; + }; + 1ED5D18B25348FC40035046C /* Library */ = { + isa = PBXGroup; + children = ( + 1ED5D18C25348FD50035046C /* XCTestCase+Stub.swift */, + ); + path = Library; + sourceTree = ""; + }; + 1ED5D18E25349E8D0035046C /* Extensions */ = { + isa = PBXGroup; + children = ( + 1ED5D18F25349E9D0035046C /* UIViewController+Rx.swift */, + 1E56172B2541007500BF26A0 /* Data+Stub.swift */, + 1EEDC69E254A301D00D75F3E /* UITableView+Extensions.swift */, + 1EEDC6A0254A331500D75F3E /* UICollectionView+Extensions.swift */, + 1E65578B254CB20D00950706 /* API+Rx.swift */, + 1E65578D254CB22800950706 /* URLRequest+Encoded.swift */, + 1E049E12254F5A5300226E0B /* RxSwift+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 1ED5D1912534A55D0035046C /* Mocks */ = { + isa = PBXGroup; + children = ( + 1ED5D1922534A56F0035046C /* FactsServiceMock.swift */, + 1E7F9F9A254CD5110062613C /* APIMock.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + 1ED5D1942534AA460035046C /* Stubs */ = { + isa = PBXGroup; + children = ( + 1ED5D19B2534AAE40035046C /* short-fact.json */, + 1ED5D1972534AA700035046C /* long-fact.json */, + 1ED5D1992534AA7A0035046C /* facts-list.json */, + 1EDF0B362541C851001931AA /* get-categories.json */, + 1E5A87C3254CD8E30039DE07 /* search-facts.json */, + 1E9489F4254DEBB200A0002C /* fact-category.json */, + ); + path = Stubs; + sourceTree = ""; + }; + 1ED5D19D2534B0D60035046C /* Scenes */ = { + isa = PBXGroup; + children = ( + 1ED5D19E2534B0E30035046C /* FactsListScene.swift */, + 1E92112E253F7A0B00DB340B /* SearchFactsScene.swift */, + ); + path = Scenes; + sourceTree = ""; + }; + 1ED5D1A02534B0E80035046C /* Tests */ = { + isa = PBXGroup; + children = ( + 1ED5D1A12534B0F40035046C /* FactsListUITests.swift */, + 1E921130253F7AAA00DB340B /* SearchFactsUITests.swift */, + ); + path = Tests; + sourceTree = ""; + }; 1EE0713025314AF500F6BF6D = { isa = PBXGroup; children = ( @@ -83,6 +540,8 @@ 1EE0715225314AF600F6BF6D /* Chuck Norris FactsTests */, 1EE0715D25314AF600F6BF6D /* Chuck Norris FactsUITests */, 1EE0713A25314AF500F6BF6D /* Products */, + 4AEC7A1E4DDCD345F1E9B6DA /* Pods */, + 9E3EC709E58402C598992352 /* Frameworks */, ); sourceTree = ""; }; @@ -99,13 +558,9 @@ 1EE0713B25314AF500F6BF6D /* Chuck Norris Facts */ = { isa = PBXGroup; children = ( - 1EE0713C25314AF500F6BF6D /* AppDelegate.swift */, - 1EE0713E25314AF500F6BF6D /* SceneDelegate.swift */, - 1EE0714025314AF500F6BF6D /* ViewController.swift */, - 1EE0714225314AF500F6BF6D /* Main.storyboard */, - 1EE0714525314AF600F6BF6D /* Assets.xcassets */, - 1EE0714725314AF600F6BF6D /* LaunchScreen.storyboard */, - 1EE0714A25314AF600F6BF6D /* Info.plist */, + 1E49C421254F9043005BB6B4 /* Core */, + 1EFE286025320614008806B9 /* Resources */, + 1EFE286125320620008806B9 /* App */, ); path = "Chuck Norris Facts"; sourceTree = ""; @@ -113,7 +568,11 @@ 1EE0715225314AF600F6BF6D /* Chuck Norris FactsTests */ = { isa = PBXGroup; children = ( - 1EE0715325314AF600F6BF6D /* Chuck_Norris_FactsTests.swift */, + 1E5617212540F42600BF26A0 /* Data */, + 1ED5D1912534A55D0035046C /* Mocks */, + 1ED5D1942534AA460035046C /* Stubs */, + 1ED5D18B25348FC40035046C /* Library */, + 1E7F15BA253324760006887B /* Scenes */, 1EE0715525314AF600F6BF6D /* Info.plist */, ); path = "Chuck Norris FactsTests"; @@ -122,12 +581,153 @@ 1EE0715D25314AF600F6BF6D /* Chuck Norris FactsUITests */ = { isa = PBXGroup; children = ( - 1EE0715E25314AF600F6BF6D /* Chuck_Norris_FactsUITests.swift */, + 1E135FAB254B4E92009D18AF /* Library */, + 1ED5D1A02534B0E80035046C /* Tests */, + 1ED5D19D2534B0D60035046C /* Scenes */, 1EE0716025314AF600F6BF6D /* Info.plist */, ); path = "Chuck Norris FactsUITests"; sourceTree = ""; }; + 1EFE286025320614008806B9 /* Resources */ = { + isa = PBXGroup; + children = ( + 1EA3AB00254B955F004A877B /* Generated */, + 1E3275902532A2C4007E838A /* Animations */, + 1EFE287C2532105D008806B9 /* Stubs */, + 1EE0714525314AF600F6BF6D /* Assets.xcassets */, + 1EE0714725314AF600F6BF6D /* LaunchScreen.storyboard */, + 1EE0714A25314AF600F6BF6D /* Info.plist */, + 1E580BC7254B92E600886A2E /* Localizable.strings */, + ); + path = Resources; + sourceTree = ""; + }; + 1EFE286125320620008806B9 /* App */ = { + isa = PBXGroup; + children = ( + 1E49C420254F8FC3005BB6B4 /* Views */, + 1EFE28632532062A008806B9 /* Scenes */, + 1EE0713C25314AF500F6BF6D /* AppDelegate.swift */, + 1EE0713E25314AF500F6BF6D /* SceneDelegate.swift */, + 1EFE2868253207B2008806B9 /* AppCoordinator.swift */, + ); + path = App; + sourceTree = ""; + }; + 1EFE28632532062A008806B9 /* Scenes */ = { + isa = PBXGroup; + children = ( + 1EFE288A25321327008806B9 /* Facts */, + ); + path = Scenes; + sourceTree = ""; + }; + 1EFE2865253206C8008806B9 /* Library */ = { + isa = PBXGroup; + children = ( + 1EFE2866253206D3008806B9 /* BaseCoordinator.swift */, + 1EFE288825321123008806B9 /* JSON.swift */, + 1EACEC9D25364B6C0006B36D /* ActivityIndicator.swift */, + 1E8A0FF32547768500565A86 /* DynamicHeightCollectionView.swift */, + 1E135FA9254B4E66009D18AF /* LaunchArgument.swift */, + ); + path = Library; + sourceTree = ""; + }; + 1EFE287C2532105D008806B9 /* Stubs */ = { + isa = PBXGroup; + children = ( + 1EFE287D25321071008806B9 /* search-facts.json */, + 1E5617272540FAF200BF26A0 /* get-categories.json */, + 1E135FAE254B52E0009D18AF /* facts.json */, + ); + path = Stubs; + sourceTree = ""; + }; + 1EFE2881253210A4008806B9 /* Data */ = { + isa = PBXGroup; + children = ( + 1E921137253F908C00DB340B /* Storage */, + 1E92112B253F6CF500DB340B /* Services */, + 1EFE288525321111008806B9 /* Models */, + 1EFE2882253210A8008806B9 /* Networking */, + ); + path = Data; + sourceTree = ""; + }; + 1EFE2882253210A8008806B9 /* Networking */ = { + isa = PBXGroup; + children = ( + 1E921128253F6BA500DB340B /* Responses */, + 1EFE2883253210B2008806B9 /* FactsAPI.swift */, + ); + path = Networking; + sourceTree = ""; + }; + 1EFE288525321111008806B9 /* Models */ = { + isa = PBXGroup; + children = ( + 1EFE288625321119008806B9 /* Fact.swift */, + 1E92113A253F90BF00DB340B /* FactCategory.swift */, + ); + path = Models; + sourceTree = ""; + }; + 1EFE288A25321327008806B9 /* Facts */ = { + isa = PBXGroup; + children = ( + 1E463801253636050079D8E9 /* SearchFacts */, + 1EFE288C25321337008806B9 /* FactsList */, + ); + path = Facts; + sourceTree = ""; + }; + 1EFE288C25321337008806B9 /* FactsList */ = { + isa = PBXGroup; + children = ( + 1E6D568D25505D3F00D27284 /* Error */, + 1E32758D2532A2A3007E838A /* Views */, + 1EFE289325321CB4008806B9 /* Fact */, + 1EFE288D2532135B008806B9 /* FactsListViewController.swift */, + 1EFE288F2532137C008806B9 /* FactsListCoordinator.swift */, + 1EFE2891253214DB008806B9 /* FactsListViewModel.swift */, + ); + path = FactsList; + sourceTree = ""; + }; + 1EFE289325321CB4008806B9 /* Fact */ = { + isa = PBXGroup; + children = ( + 1EFE289625321CE2008806B9 /* FactCell.swift */, + 1EFE289425321CD2008806B9 /* FactViewModel.swift */, + ); + path = Fact; + sourceTree = ""; + }; + 4AEC7A1E4DDCD345F1E9B6DA /* Pods */ = { + isa = PBXGroup; + children = ( + BBB43EE571174B99828001E3 /* Pods-Chuck Norris Facts.debug.xcconfig */, + 74304E6C0D317767335DC2AB /* Pods-Chuck Norris Facts.release.xcconfig */, + 21EE31E3903E76B09B8FBA96 /* Pods-Chuck Norris Facts-Chuck Norris FactsUITests.debug.xcconfig */, + BA72CEAC53E67FEFA608F6B0 /* Pods-Chuck Norris Facts-Chuck Norris FactsUITests.release.xcconfig */, + EA8CB0ABD6CE41AE1F636AB6 /* Pods-Chuck Norris FactsTests.debug.xcconfig */, + 5BFEC696AC24CC9BF87C5195 /* Pods-Chuck Norris FactsTests.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 9E3EC709E58402C598992352 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 02186229E02F0A408D2EE0C2 /* Pods_Chuck_Norris_Facts.framework */, + 7C1F7C697FAAC85D6A1F91F4 /* Pods_Chuck_Norris_Facts_Chuck_Norris_FactsUITests.framework */, + EF9A8C577644798596588861 /* Pods_Chuck_Norris_FactsTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -135,9 +735,13 @@ isa = PBXNativeTarget; buildConfigurationList = 1EE0716325314AF600F6BF6D /* Build configuration list for PBXNativeTarget "Chuck Norris Facts" */; buildPhases = ( + B4BFEDAE06C957BE8D534340 /* [CP] Check Pods Manifest.lock */, 1EE0713525314AF500F6BF6D /* Sources */, 1EE0713625314AF500F6BF6D /* Frameworks */, 1EE0713725314AF500F6BF6D /* Resources */, + 1EFBC28C2531F8FB00594676 /* SwiftLint */, + 1E580BC6254B919900886A2E /* SwiftGen */, + 735DB507838707E07E18E902 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -152,9 +756,11 @@ isa = PBXNativeTarget; buildConfigurationList = 1EE0716625314AF600F6BF6D /* Build configuration list for PBXNativeTarget "Chuck Norris FactsTests" */; buildPhases = ( + 1D4F1BF917788782D822C23A /* [CP] Check Pods Manifest.lock */, 1EE0714B25314AF600F6BF6D /* Sources */, 1EE0714C25314AF600F6BF6D /* Frameworks */, 1EE0714D25314AF600F6BF6D /* Resources */, + BAF061AC1596B6EFF9116436 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -170,9 +776,11 @@ isa = PBXNativeTarget; buildConfigurationList = 1EE0716925314AF600F6BF6D /* Build configuration list for PBXNativeTarget "Chuck Norris FactsUITests" */; buildPhases = ( + 47A6A25B92C403E329DCC297 /* [CP] Check Pods Manifest.lock */, 1EE0715625314AF600F6BF6D /* Sources */, 1EE0715725314AF600F6BF6D /* Frameworks */, 1EE0715825314AF600F6BF6D /* Resources */, + 431049B51AA3B09194F28889 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -191,7 +799,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1170; - LastUpgradeCheck = 1170; + LastUpgradeCheck = 1220; ORGANIZATIONNAME = "Djorkaeff Alexandre Vilela Pereira"; TargetAttributes = { 1EE0713825314AF500F6BF6D = { @@ -232,9 +840,14 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1E135FAF254B52E0009D18AF /* facts.json in Resources */, + 1EFE287E25321071008806B9 /* search-facts.json in Resources */, 1EE0714925314AF600F6BF6D /* LaunchScreen.storyboard in Resources */, + 1E5617282540FAF200BF26A0 /* get-categories.json in Resources */, 1EE0714625314AF600F6BF6D /* Assets.xcassets in Resources */, - 1EE0714425314AF500F6BF6D /* Main.storyboard in Resources */, + 1E580BC8254B92E600886A2E /* Localizable.strings in Resources */, + 1E3275922532A2CD007E838A /* empty-box.json in Resources */, + 1EACEC99253649BD0006B36D /* loading.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -242,6 +855,12 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1ED5D19C2534AAE40035046C /* short-fact.json in Resources */, + 1ED5D19A2534AA7A0035046C /* facts-list.json in Resources */, + 1EDF0B372541C851001931AA /* get-categories.json in Resources */, + 1E9489F5254DEBB200A0002C /* fact-category.json in Resources */, + 1E5A87C4254CD8E30039DE07 /* search-facts.json in Resources */, + 1ED5D1982534AA700035046C /* long-fact.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -254,14 +873,218 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 1D4F1BF917788782D822C23A /* [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-Chuck Norris FactsTests-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; + }; + 1E580BC6254B919900886A2E /* SwiftGen */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = SwiftGen; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then\n \"${PODS_ROOT}/SwiftGen/bin/swiftgen\"\nelse\n echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; + }; + 1EFBC28C2531F8FB00594676 /* SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = SwiftLint; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; + }; + 431049B51AA3B09194F28889 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Chuck Norris Facts-Chuck Norris FactsUITests/Pods-Chuck Norris Facts-Chuck Norris FactsUITests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Chuck Norris Facts-Chuck Norris FactsUITests/Pods-Chuck Norris Facts-Chuck Norris FactsUITests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Chuck Norris Facts-Chuck Norris FactsUITests/Pods-Chuck Norris Facts-Chuck Norris FactsUITests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 47A6A25B92C403E329DCC297 /* [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-Chuck Norris Facts-Chuck Norris FactsUITests-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; + }; + 735DB507838707E07E18E902 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Chuck Norris Facts/Pods-Chuck Norris Facts-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Chuck Norris Facts/Pods-Chuck Norris Facts-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Chuck Norris Facts/Pods-Chuck Norris Facts-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + B4BFEDAE06C957BE8D534340 /* [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-Chuck Norris Facts-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; + }; + BAF061AC1596B6EFF9116436 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Chuck Norris FactsTests/Pods-Chuck Norris FactsTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Chuck Norris FactsTests/Pods-Chuck Norris FactsTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Chuck Norris FactsTests/Pods-Chuck Norris FactsTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 1EE0713525314AF500F6BF6D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1EE0714125314AF500F6BF6D /* ViewController.swift in Sources */, + 1EFE2884253210B2008806B9 /* FactsAPI.swift in Sources */, + 1E3075C2254C9D0B0082A194 /* APITarget.swift in Sources */, + 1E65578C254CB20D00950706 /* API+Rx.swift in Sources */, + 1E32758F2532A2C0007E838A /* EmptyListView.swift in Sources */, + 1E8A0FF0254760D400565A86 /* SuggestionsViewModel.swift in Sources */, + 1E3075C5254C9D710082A194 /* HTTPMethod.swift in Sources */, + 1E463805253636D80079D8E9 /* SearchFactsCoordinator.swift in Sources */, + 1E049E13254F5A5300226E0B /* RxSwift+Extensions.swift in Sources */, + 1EFE288925321123008806B9 /* JSON.swift in Sources */, + 1ED06C952548AAD300139151 /* LoadingView.swift in Sources */, + 1EFE288725321119008806B9 /* Fact.swift in Sources */, + 1EA3AB02254B956C004A877B /* Strings.swift in Sources */, + 1EFE289725321CE2008806B9 /* FactCell.swift in Sources */, + 1E56172C2541007500BF26A0 /* Data+Stub.swift in Sources */, + 1E8A0FEE2547603700565A86 /* SuggestionsCell.swift in Sources */, + 1E8AF33A254793D800BBB808 /* PastSearchCell.swift in Sources */, 1EE0713D25314AF500F6BF6D /* AppDelegate.swift in Sources */, + 1E3075C9254CA5F70082A194 /* APIProvider.swift in Sources */, + 1E8A0FEC25475B0800565A86 /* SearchFactsTableViewSection.swift in Sources */, + 1EF066E52545CEC200ECF611 /* SearchEntity.swift in Sources */, + 1ED5D19025349E9D0035046C /* UIViewController+Rx.swift in Sources */, + 1E236840253FB13A00BE17F3 /* FactCategoryViewModel.swift in Sources */, + 1E65578E254CB22800950706 /* URLRequest+Encoded.swift in Sources */, + 1EFE2869253207B2008806B9 /* AppCoordinator.swift in Sources */, + 1E92112A253F6BB700DB340B /* SearchFactsResponse.swift in Sources */, + 1EEDC6A5254A408B00D75F3E /* FactsListError.swift in Sources */, 1EE0713F25314AF500F6BF6D /* SceneDelegate.swift in Sources */, + 1E8A0FF42547768500565A86 /* DynamicHeightCollectionView.swift in Sources */, + 1EFE288E2532135B008806B9 /* FactsListViewController.swift in Sources */, + 1EEDC6A1254A331500D75F3E /* UICollectionView+Extensions.swift in Sources */, + 1E655788254CB13B00950706 /* APIResponse.swift in Sources */, + 1EF066E32545CE1D00ECF611 /* PastSearchViewModel.swift in Sources */, + 1E655786254CB0FF00950706 /* APIError.swift in Sources */, + 1EFE28902532137C008806B9 /* FactsListCoordinator.swift in Sources */, + 1E463803253636160079D8E9 /* SearchFactsViewController.swift in Sources */, + 1EEDC69F254A301D00D75F3E /* UITableView+Extensions.swift in Sources */, + 1EFE289525321CD2008806B9 /* FactViewModel.swift in Sources */, + 1E46380B25363FF40079D8E9 /* SearchFactsViewModel.swift in Sources */, + 1E921139253F909700DB340B /* FactsStorage.swift in Sources */, + 1E23683E253FB07200BE17F3 /* FactCategoryCell.swift in Sources */, + 1E92113E253F915100DB340B /* FactCategoryEntity.swift in Sources */, + 1EF0DA1425449898005CF7E2 /* CategoryView.swift in Sources */, + 1E7A6528254DA2B1006E493B /* HTTPTask.swift in Sources */, + 1EAB20AF2540BEC400633382 /* SuggestionsViewFlowLayout.swift in Sources */, + 1E92112D253F6D0000DB340B /* FactsService.swift in Sources */, + 1EFE2892253214DB008806B9 /* FactsListViewModel.swift in Sources */, + 1EACEC9E25364B6C0006B36D /* ActivityIndicator.swift in Sources */, + 1E135FAA254B4E66009D18AF /* LaunchArgument.swift in Sources */, + 1EFE2867253206D3008806B9 /* BaseCoordinator.swift in Sources */, + 1E92113B253F90BF00DB340B /* FactCategory.swift in Sources */, + 1E6D568F25505D5700D27284 /* FactsListErrorViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -269,7 +1092,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1EE0715425314AF600F6BF6D /* Chuck_Norris_FactsTests.swift in Sources */, + 1E56172E2541039B00BF26A0 /* SearchFactsViewControllerTests.swift in Sources */, + 1E921134253F84F100DB340B /* SearchFactsViewModelTests.swift in Sources */, + 1E7F15C6253329780006887B /* FactViewModelTests.swift in Sources */, + 1E5617242540F43F00BF26A0 /* FactsServiceTests.swift in Sources */, + 1ED5D18D25348FD50035046C /* XCTestCase+Stub.swift in Sources */, + 1ED5D1932534A56F0035046C /* FactsServiceMock.swift in Sources */, + 1E9489F3254DEB2500A0002C /* FactCategoryViewModelTests.swift in Sources */, + 1E7F9F9B254CD5110062613C /* APIMock.swift in Sources */, + 1E7F15BE253324CF0006887B /* FactsListViewModelTests.swift in Sources */, + 1E7F15C0253324FD0006887B /* FactsListViewControllerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -277,7 +1109,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1EE0715F25314AF600F6BF6D /* Chuck_Norris_FactsUITests.swift in Sources */, + 1ED5D1A22534B0F40035046C /* FactsListUITests.swift in Sources */, + 1ED5D19F2534B0E30035046C /* FactsListScene.swift in Sources */, + 1E135FAD254B4E9F009D18AF /* XCUIApplication+LaunchArgument.swift in Sources */, + 1E92112F253F7A0B00DB340B /* SearchFactsScene.swift in Sources */, + 1E921131253F7AAA00DB340B /* SearchFactsUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -297,14 +1133,6 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ - 1EE0714225314AF500F6BF6D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 1EE0714325314AF500F6BF6D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; 1EE0714725314AF600F6BF6D /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -342,6 +1170,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -402,6 +1231,7 @@ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -432,11 +1262,12 @@ }; 1EE0716425314AF600F6BF6D /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = BBB43EE571174B99828001E3 /* Pods-Chuck Norris Facts.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PHM6B5Y356; - INFOPLIST_FILE = "Chuck Norris Facts/Info.plist"; + INFOPLIST_FILE = "Chuck Norris Facts/Resources/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -450,11 +1281,12 @@ }; 1EE0716525314AF600F6BF6D /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 74304E6C0D317767335DC2AB /* Pods-Chuck Norris Facts.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PHM6B5Y356; - INFOPLIST_FILE = "Chuck Norris Facts/Info.plist"; + INFOPLIST_FILE = "Chuck Norris Facts/Resources/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -468,8 +1300,9 @@ }; 1EE0716725314AF600F6BF6D /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = EA8CB0ABD6CE41AE1F636AB6 /* Pods-Chuck Norris FactsTests.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PHM6B5Y356; @@ -490,8 +1323,9 @@ }; 1EE0716825314AF600F6BF6D /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5BFEC696AC24CC9BF87C5195 /* Pods-Chuck Norris FactsTests.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PHM6B5Y356; @@ -512,8 +1346,9 @@ }; 1EE0716A25314AF600F6BF6D /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 21EE31E3903E76B09B8FBA96 /* Pods-Chuck Norris Facts-Chuck Norris FactsUITests.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PHM6B5Y356; INFOPLIST_FILE = "Chuck Norris FactsUITests/Info.plist"; @@ -532,8 +1367,9 @@ }; 1EE0716B25314AF600F6BF6D /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = BA72CEAC53E67FEFA608F6B0 /* Pods-Chuck Norris Facts-Chuck Norris FactsUITests.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PHM6B5Y356; INFOPLIST_FILE = "Chuck Norris FactsUITests/Info.plist"; diff --git a/Chuck Norris Facts.xcodeproj/xcshareddata/xcschemes/Chuck Norris Facts.xcscheme b/Chuck Norris Facts.xcodeproj/xcshareddata/xcschemes/Chuck Norris Facts.xcscheme new file mode 100644 index 0000000..8604fd7 --- /dev/null +++ b/Chuck Norris Facts.xcodeproj/xcshareddata/xcschemes/Chuck Norris Facts.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Chuck Norris Facts.xcworkspace/contents.xcworkspacedata b/Chuck Norris Facts.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..79c6c09 --- /dev/null +++ b/Chuck Norris Facts.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Chuck Norris Facts.xcodeproj/xcuserdata/djorkaeff.xcuserdatad/xcschemes/xcschememanagement.plist b/Chuck Norris Facts.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 53% rename from Chuck Norris Facts.xcodeproj/xcuserdata/djorkaeff.xcuserdatad/xcschemes/xcschememanagement.plist rename to Chuck Norris Facts.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist index 6508c79..18d9810 100644 --- a/Chuck Norris Facts.xcodeproj/xcuserdata/djorkaeff.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Chuck Norris Facts.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -2,13 +2,7 @@ - SchemeUserState - - Chuck Norris Facts.xcscheme_^#shared#^_ - - orderHint - 0 - - + IDEDidComputeMac32BitWarning + diff --git a/Chuck Norris Facts/App/AppCoordinator.swift b/Chuck Norris Facts/App/AppCoordinator.swift new file mode 100644 index 0000000..00b8889 --- /dev/null +++ b/Chuck Norris Facts/App/AppCoordinator.swift @@ -0,0 +1,24 @@ +// +// AppCoordinator.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RxSwift + +class AppCoordinator: BaseCoordinator { + + private let window: UIWindow + + init(window: UIWindow) { + self.window = window + } + + override func start() -> Observable { + let factsListCoordinator = FactsListCoordinator(window: window) + return coordinate(to: factsListCoordinator) + } +} diff --git a/Chuck Norris Facts/App/AppDelegate.swift b/Chuck Norris Facts/App/AppDelegate.swift new file mode 100644 index 0000000..293b0b3 --- /dev/null +++ b/Chuck Norris Facts/App/AppDelegate.swift @@ -0,0 +1,51 @@ +// +// AppDelegate.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/9/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RealmSwift + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + + processArguments() + + return true + } + + private func processArguments() { + if LaunchArgument.check(.uiTest) { + UIView.setAnimationsEnabled(false) + } + + if LaunchArgument.check(.resetData) { + let realm = try? Realm() + try? realm?.write { + realm?.deleteAll() + } + } + + if LaunchArgument.check(.mockStorage) { + let entities = [ + SearchEntity(searchTerm: "games"), + SearchEntity(searchTerm: "fashion"), + FactCategoryEntity(category: FactCategory(text: "games")), + FactCategoryEntity(category: FactCategory(text: "fashion")) + ] + + let realm = try? Realm() + try? realm?.write { + realm?.add(entities, update: .modified) + } + } + } +} diff --git a/Chuck Norris Facts/App/SceneDelegate.swift b/Chuck Norris Facts/App/SceneDelegate.swift new file mode 100644 index 0000000..986e5cc --- /dev/null +++ b/Chuck Norris Facts/App/SceneDelegate.swift @@ -0,0 +1,34 @@ +// +// SceneDelegate.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/9/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RxSwift + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + private var appCoordinator: AppCoordinator? + private let disposeBag = DisposeBag() + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let scene = (scene as? UIWindowScene) else { return } + + let window = UIWindow(windowScene: scene) + self.window = window + + appCoordinator = AppCoordinator(window: window) + appCoordinator?.start() + .subscribe() + .disposed(by: disposeBag) + } + +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Error/FactsListError.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Error/FactsListError.swift new file mode 100644 index 0000000..5fff962 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Error/FactsListError.swift @@ -0,0 +1,55 @@ +// +// FactsListError.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/28/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +enum FactsListError { + // Error related to syncCategories request + case syncCategories(Error) + // Error related to searchFacts request + case searchFacts(Error) +} + +extension FactsListError { + + // APIError related to the error. + var error: APIError { + switch self { + case .syncCategories(let error): + return (error as? APIError) ?? APIError.unknown(error) + case .searchFacts(let error): + return (error as? APIError) ?? APIError.unknown(error) + } + } + + // A code to check where the error come. + var code: Int { + switch self { + case .syncCategories: + return 0 + case .searchFacts: + return 1 + } + } + + // A message that will be shown to user. + var message: String { + switch self { + case .syncCategories: + return L10n.Errors.cantSyncCategories + case .searchFacts: + return L10n.Errors.cantSearchFacts + } + } +} + +extension FactsListError: Equatable { + static func == (lhs: FactsListError, rhs: FactsListError) -> Bool { + lhs.code == rhs.code + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Error/FactsListErrorViewModel.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Error/FactsListErrorViewModel.swift new file mode 100644 index 0000000..92f943a --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Error/FactsListErrorViewModel.swift @@ -0,0 +1,31 @@ +// +// FactsListErrorViewModel.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 11/2/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +struct FactsListErrorViewModel { + + let error: APIError + let title: String + let message: String + var shouldRetry: Bool = false + + init(factsListError: FactsListError) { + self.error = factsListError.error + + self.title = factsListError.message + self.message = error.message + + switch factsListError { + case .syncCategories: + self.shouldRetry = error.code != APIError.noConnection.code + default: + break + } + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Fact/FactCell.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Fact/FactCell.swift new file mode 100644 index 0000000..9e4fe3f --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Fact/FactCell.swift @@ -0,0 +1,117 @@ +// +// FactCell.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RxSwift + +final class FactCell: UITableViewCell { + + var disposeBag = DisposeBag() + + private lazy var categoryView: CategoryView = { + let categoryView = CategoryView() + categoryView.translatesAutoresizingMaskIntoConstraints = false + return categoryView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + disposeBag = DisposeBag() + super.prepareForReuse() + } + + private lazy var shadowView: UIView = { + let view = UIView() + + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .systemBackground + + view.layer.shadowColor = UIColor.systemGray.cgColor + view.layer.shadowOpacity = 0.5 + view.layer.shadowOffset = .zero + view.layer.cornerRadius = 16 + + return view + }() + + lazy var bodyLabel: UILabel = { + let label = UILabel() + + label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byWordWrapping + label.numberOfLines = 0 + + return label + }() + + lazy var shareButton: UIButton = { + let button = UIButton(type: .system) + + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityLabel = "Share" + button.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal) + button.accessibilityIdentifier = "shareFactButton" + + return button + }() + + private func setupView() { + let padding: CGFloat = 16 + + clipsToBounds = false + selectionStyle = .none + + contentView.clipsToBounds = false + contentView.addSubview(shadowView) + + NSLayoutConstraint.activate([ + shadowView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding), + shadowView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding), + shadowView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding / 2), + shadowView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding / 2) + ]) + + shadowView.addSubview(bodyLabel) + NSLayoutConstraint.activate([ + bodyLabel.topAnchor.constraint(equalTo: shadowView.topAnchor, constant: padding), + bodyLabel.leadingAnchor.constraint(equalTo: shadowView.leadingAnchor, constant: padding), + bodyLabel.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor, constant: -padding) + ]) + + shadowView.addSubview(shareButton) + NSLayoutConstraint.activate([ + shareButton.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: padding), + shareButton.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor, constant: -padding), + shareButton.bottomAnchor.constraint(equalTo: shadowView.bottomAnchor, constant: -padding) + ]) + + shadowView.addSubview(categoryView) + NSLayoutConstraint.activate([ + categoryView.centerYAnchor.constraint(equalTo: shareButton.centerYAnchor), + categoryView.leftAnchor.constraint(equalTo: shadowView.leftAnchor, constant: padding) + ]) + } + + func setup(_ fact: FactViewModel) { + bodyLabel.text = fact.text + + bodyLabel.font = fact.text.count > 80 + ? UIFont.preferredFont(forTextStyle: .title3) + : UIFont.preferredFont(forTextStyle: .title1) + + categoryView.label.text = fact.category + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Fact/FactViewModel.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Fact/FactViewModel.swift new file mode 100644 index 0000000..86cd859 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Fact/FactViewModel.swift @@ -0,0 +1,40 @@ +// +// FactViewModel.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RxDataSources + +final class FactViewModel { + let fact: Fact + + let text: String + let category: String + var url: URL? + + init(fact: Fact) { + self.fact = fact + self.text = fact.value + self.category = fact.categories.first?.text.uppercased() ?? L10n.FactCategory.uncategorized + + if let url = fact.url { + self.url = URL(string: url) + } + } +} + +extension FactViewModel: IdentifiableType { + var identity: String { + fact.id + } +} + +extension FactViewModel: Equatable { + static func == (lhs: FactViewModel, rhs: FactViewModel) -> Bool { + return lhs.fact == rhs.fact + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListCoordinator.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListCoordinator.swift new file mode 100644 index 0000000..1249619 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListCoordinator.swift @@ -0,0 +1,99 @@ +// +// FactsListCoordinator.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RxSwift + +final class FactsListCoordinator: BaseCoordinator { + + private let window: UIWindow + + init(window: UIWindow) { + self.window = window + } + + override func start() -> Observable { + let factsListViewModel = FactsListViewModel() + let factsListViewController = FactsListViewController() + factsListViewController.viewModel = factsListViewModel + + let navigationController = UINavigationController(rootViewController: factsListViewController) + + factsListViewModel.outputs.showShareFact + .bind(onNext: { [weak self] in + self?.showShareFact(fact: $0, in: navigationController) + }) + .disposed(by: disposeBag) + + factsListViewModel.outputs.showSearchFacts + .flatMap { [weak self] _ -> Observable in + self?.showSearchFacts(on: factsListViewController) ?? .empty() + } + .compactMap { $0 } + .bind(to: factsListViewModel.inputs.setSearchTerm) + .disposed(by: disposeBag) + + factsListViewModel.outputs.factsListError + .flatMap { [weak self] error in + self?.showFactsListError(error: error, in: navigationController) ?? .empty() + } + .filter { $0.shouldRetry } + .mapToVoid() + .bind(to: factsListViewModel.inputs.retryAction) + .disposed(by: disposeBag) + + window.rootViewController = navigationController + window.makeKeyAndVisible() + + return Observable.never() + } + + private func showShareFact(fact: FactViewModel, in navigationController: UINavigationController) { + var activityItems: [Any] = [fact.text] + + if let factUrl = fact.url { + activityItems.append(factUrl) + } + + let shareActivity = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + navigationController.present(shareActivity, animated: true, completion: nil) + } + + private func showSearchFacts(on rootViewController: UIViewController) -> Observable { + let searchFactsCoordinator = SearchFactsCoordinator(rootViewController: rootViewController) + return coordinate(to: searchFactsCoordinator) + .map { result in + switch result { + case .cancel: return nil + case .search(let searchTerm): return searchTerm + } + } + } + + private func showFactsListError( + error: FactsListErrorViewModel, + in navigationController: UINavigationController + ) -> Observable { + Observable.create { observer in + let alert = UIAlertController(title: error.title, message: error.message, preferredStyle: .alert) + + let action = UIAlertAction(title: L10n.Common.ok, style: .default) { _ in + observer.onNext(error) + observer.onCompleted() + } + + alert.addAction(action) + + navigationController.present(alert, animated: true, completion: nil) + + return Disposables.create { + alert.dismiss(animated: true, completion: nil) + } + } + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewController.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewController.swift new file mode 100644 index 0000000..c71ff14 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewController.swift @@ -0,0 +1,174 @@ +// +// FactsListViewController.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RxSwift +import RxCocoa +import RxDataSources +import Lottie + +final class FactsListViewController: UIViewController { + + var viewModel: FactsListViewModel! + + private let disposeBag = DisposeBag() + + let tableView = UITableView() + let loadingView = LoadingView() + let emptyListView = EmptyListView() + let searchButton = UIBarButtonItem(barButtonSystemItem: .search, target: nil, action: nil) + + private lazy var factsDataSource = RxTableViewSectionedAnimatedDataSource( + configureCell: { [weak self] _, tableView, indexPath, fact -> UITableViewCell in + + guard let viewModel = self?.viewModel, let disposeBag = self?.disposeBag else { return UITableViewCell() } + let cell = tableView.dequeueReusableCell(cell: FactCell.self, indexPath: indexPath) + + cell.setup(fact) + cell.shareButton.rx.tap + .map { fact } + .bind(to: viewModel.inputs.startShareFact) + .disposed(by: cell.disposeBag) + + return cell + } + ) + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + setupBindings() + setupTableView() + setupEmptyListView() + setupLoadingView() + setupNavigationBar() + } + + private func setupView() { + view.backgroundColor = .systemBackground + } + + private func setupTableView() { + view.addSubview(tableView) + + tableView.separatorStyle = .none + + tableView.register(FactCell.self) + tableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + tableView.accessibilityIdentifier = "factsTableView" + } + + private func setupLoadingView() { + view.addSubview(loadingView) + + loadingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + loadingView.topAnchor.constraint(equalTo: view.topAnchor), + loadingView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + loadingView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + loadingView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func setupEmptyListView() { + view.addSubview(emptyListView) + + emptyListView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + emptyListView.topAnchor.constraint(equalTo: view.topAnchor), + emptyListView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + emptyListView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + emptyListView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func setupNavigationBar() { + navigationItem.title = L10n.FactsList.title + navigationItem.rightBarButtonItem = searchButton + navigationController?.navigationBar.prefersLargeTitles = true + + searchButton.accessibilityIdentifier = "searchButton" + } + + private func setupBindings() { + rx.viewDidAppear + .bind(to: viewModel.inputs.viewDidAppear) + .disposed(by: disposeBag) + + viewModel.outputs.isLoading + .drive(onNext: { [weak self] isLoading in + self?.showLoadingView(isLoading) + }) + .disposed(by: disposeBag) + + let factsIsEmpty = viewModel.outputs.facts + .map { $0.flatMap { $0.items } } + .map { $0.isEmpty } + .share() + + let searchIsEmpty = viewModel.outputs.searchTerm + .map { $0.isEmpty } + .share() + + Observable.combineLatest(factsIsEmpty, searchIsEmpty) + .asDriver(onErrorJustReturn: (true, true)) + .drive(onNext: { [weak self] listEmpty, searchEmpty in + self?.showEmptyView(listEmpty, searchEmpty) + }) + .disposed(by: disposeBag) + + viewModel.outputs.facts + .observeOn(MainScheduler.instance) + .bind(to: tableView.rx.items(dataSource: factsDataSource)) + .disposed(by: disposeBag) + + searchButton.rx.tap + .bind(to: viewModel.inputs.startSearchFacts) + .disposed(by: disposeBag) + + emptyListView.searchButton.rx.tap + .bind(to: viewModel.inputs.startSearchFacts) + .disposed(by: disposeBag) + } + + private func showEmptyView(_ listEmpty: Bool, _ searchEmpty: Bool) { + emptyListView.isHidden = !listEmpty + + if searchEmpty { + emptyListView.label.text = L10n.EmptyView.empty + emptyListView.searchButton.isHidden = false + } else { + emptyListView.label.text = L10n.EmptyView.emptySearch + emptyListView.searchButton.isHidden = true + } + + if listEmpty { + emptyListView.play() + } else { + emptyListView.stop() + } + } + + private func showLoadingView(_ isLoading: Bool) { + loadingView.isHidden = !isLoading + + if isLoading { + loadingView.play() + } else { + loadingView.stop() + } + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewModel.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewModel.swift new file mode 100644 index 0000000..24402cd --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewModel.swift @@ -0,0 +1,139 @@ +// +// FactsListViewModel.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RxSwift +import RxDataSources + +typealias FactsSectionModel = AnimatableSectionModel + +protocol FactsListViewModelInputs { + // Call when view did appear to syncCategories + var viewDidAppear: AnyObserver { get } + + // Call when user start to share a fact + var startShareFact: AnyObserver { get } + + // Call to show SearchFacts scene + var startSearchFacts: AnyObserver { get } + + // Call to set SearchTerm to be used on search + var setSearchTerm: AnyObserver { get } + + // Call to retry a syncCategories action + var retryAction: AnyObserver { get } +} + +protocol FactsListViewModelOutputs { + // Emmits an array of FactsSectionModel to bind on tableView + var facts: Observable<[FactsSectionModel]> { get } + + // Emmits an FactViewModel to be shared + var showShareFact: Observable { get } + + // Emmits an event to show SearchFacts scene + var showSearchFacts: Observable { get } + + // Emmits an string to be used as a search query and check empty state + var searchTerm: Observable { get } + + // Emmits an ActivityIndicator to check if there is a facts search happening + var isLoading: ActivityIndicator { get } + + // Emmits an FactsListErrorViewModel to be shown + var factsListError: Observable { get } +} + +final class FactsListViewModel: FactsListViewModelInputs, FactsListViewModelOutputs { + + var inputs: FactsListViewModelInputs { self } + + var outputs: FactsListViewModelOutputs { self } + + // MARK: - Inputs + + var viewDidAppear: AnyObserver + + var startShareFact: AnyObserver + + var startSearchFacts: AnyObserver + + var setSearchTerm: AnyObserver + + var retryAction: AnyObserver + + // MARK: - Outputs + + var facts: Observable<[FactsSectionModel]> + + var showShareFact: Observable + + var showSearchFacts: Observable + + var searchTerm: Observable + + var isLoading: ActivityIndicator + + var factsListError: Observable + + init(factsService: FactsServiceType = FactsService()) { + let loadingIndicator = ActivityIndicator() + self.isLoading = loadingIndicator + + let viewDidAppearSubject = PublishSubject() + self.viewDidAppear = viewDidAppearSubject.asObserver() + + let startShareFactSubject = PublishSubject() + self.startShareFact = startShareFactSubject.asObserver() + self.showShareFact = startShareFactSubject.asObservable() + + let startSearchFactsSubject = PublishSubject() + self.startSearchFacts = startSearchFactsSubject.asObserver() + self.showSearchFacts = startSearchFactsSubject.asObservable() + + let searchTermSubject = BehaviorSubject(value: "") + self.setSearchTerm = searchTermSubject.asObserver() + self.searchTerm = searchTermSubject.asObservable() + + let retryActionSubject = PublishSubject() + self.retryAction = retryActionSubject.asObserver() + + let currentErrorSubject = BehaviorSubject(value: nil) + + let retrySyncCategories = retryActionSubject.withLatestFrom(currentErrorSubject) + .compactMap { $0 } + .filter { $0 == .syncCategories($0.error) } + .mapToVoid() + + let syncCategoriesError = Observable.merge(viewDidAppearSubject, retrySyncCategories) + .flatMapLatest { _ in + factsService.syncCategories() + .materialize() + } + .errors() + .map { FactsListError.syncCategories($0) } + + let searchFacts = searchTermSubject + .flatMapLatest { searchTerm in + factsService.searchFacts(searchTerm: searchTerm) + .trackActivity(loadingIndicator) + .materialize() + } + + let searchFactsError = searchFacts.errors() + .map { FactsListError.searchFacts($0) } + + self.facts = searchFacts.elements() + .map { $0.map { FactViewModel(fact: $0) } } + .map { [FactsSectionModel(model: "", items: $0)] } + + self.factsListError = Observable.merge(syncCategoriesError, searchFactsError) + .do(onNext: currentErrorSubject.onNext) + .map { FactsListErrorViewModel(factsListError: $0) } + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/EmptyListView.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/EmptyListView.swift new file mode 100644 index 0000000..0ea9a71 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/EmptyListView.swift @@ -0,0 +1,93 @@ +// +// EmptyView.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import Lottie + +final class EmptyListView: UIView { + + private lazy var animation: AnimationView = { + let animation = AnimationView() + + animation.translatesAutoresizingMaskIntoConstraints = false + animation.animation = Animation.named("empty-box") + animation.loopMode = .loop + + return animation + }() + + lazy var label: UILabel = { + let label = UILabel() + + label.accessibilityIdentifier = "emptyListLabelView" + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .preferredFont(forTextStyle: .headline) + label.lineBreakMode = .byWordWrapping + label.numberOfLines = 0 + + return label + }() + + lazy var searchButton: UIButton = { + let button = UIButton(type: .system) + + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityLabel = "Search" + button.setTitle(L10n.EmptyView.search, for: .normal) + button.titleLabel?.font = .preferredFont(forTextStyle: .body) + button.accessibilityIdentifier = "searchButton" + + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + let animationSize: CGFloat = 200 + + backgroundColor = .systemBackground + + addSubview(animation) + NSLayoutConstraint.activate([ + animation.widthAnchor.constraint(equalToConstant: animationSize), + animation.heightAnchor.constraint(equalToConstant: animationSize), + animation.centerXAnchor.constraint(equalTo: centerXAnchor), + animation.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + + addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: animation.bottomAnchor), + label.centerXAnchor.constraint(equalTo: animation.centerXAnchor) + ]) + + addSubview(searchButton) + NSLayoutConstraint.activate([ + searchButton.topAnchor.constraint(equalTo: label.bottomAnchor), + searchButton.centerXAnchor.constraint(equalTo: label.centerXAnchor) + ]) + + accessibilityIdentifier = "emptyListView" + } + + func play() { + animation.play() + } + + func stop() { + animation.stop() + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/LoadingView.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/LoadingView.swift new file mode 100644 index 0000000..0dab464 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/LoadingView.swift @@ -0,0 +1,55 @@ +// +// LoadingView.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/27/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import Lottie + +final class LoadingView: UIView { + + private lazy var animation: AnimationView = { + let loading = AnimationView() + + loading.translatesAutoresizingMaskIntoConstraints = false + loading.animation = Animation.named("loading") + loading.loopMode = .loop + + return loading + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + let animationSize: CGFloat = 48 + + backgroundColor = .systemBackground + + addSubview(animation) + NSLayoutConstraint.activate([ + animation.widthAnchor.constraint(equalToConstant: animationSize), + animation.heightAnchor.constraint(equalToConstant: animationSize), + animation.centerXAnchor.constraint(equalTo: centerXAnchor), + animation.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + func play() { + animation.play() + } + + func stop() { + animation.stop() + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchCell.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchCell.swift new file mode 100644 index 0000000..8abb4ba --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchCell.swift @@ -0,0 +1,19 @@ +// +// PastSearchCell.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/26/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit + +final class PastSearchCell: UITableViewCell { + + static let identifier = "PastSearchCell" + + func setup(_ pastSearch: PastSearchViewModel) { + textLabel?.text = pastSearch.text + imageView?.image = UIImage(systemName: "magnifyingglass") + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchViewModel.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchViewModel.swift new file mode 100644 index 0000000..2c35a32 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchViewModel.swift @@ -0,0 +1,26 @@ +// +// PastSearchViewModel.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/25/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RxDataSources + +struct PastSearchViewModel { + let text: String +} + +extension PastSearchViewModel: IdentifiableType { + var identity: String { + text + } +} + +extension PastSearchViewModel: Equatable { + static func == (lhs: PastSearchViewModel, rhs: PastSearchViewModel) -> Bool { + return lhs.text == rhs.text + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsCoordinator.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsCoordinator.swift new file mode 100644 index 0000000..8008375 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsCoordinator.swift @@ -0,0 +1,42 @@ +// +// SearchFactsCoordinator.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/13/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RxSwift + +enum SearchFactsCoordinationResult { + case cancel + case search(String) +} + +final class SearchFactsCoordinator: BaseCoordinator { + + private let rootViewController: UIViewController + + init(rootViewController: UIViewController) { + self.rootViewController = rootViewController + } + + override func start() -> Observable { + let searchFactsViewController = SearchFactsViewController() + let navigationController = UINavigationController(rootViewController: searchFactsViewController) + + let searchFactsViewModel = SearchFactsViewModel() + searchFactsViewController.viewModel = searchFactsViewModel + + let cancelSearchFacts = searchFactsViewModel.outputs.didCancel.map { _ in CoordinationResult.cancel } + let selectSearchTerm = searchFactsViewModel.outputs.didSelectItem.map { CoordinationResult.search($0) } + let searchFacts = searchFactsViewModel.outputs.didSearchFacts.map { CoordinationResult.search($0) } + + rootViewController.present(navigationController, animated: true) + + return Observable.merge(cancelSearchFacts, selectSearchTerm, searchFacts) + .take(1) + .do(onNext: { [weak self] _ in self?.rootViewController.dismiss(animated: true) }) + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsTableViewSection.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsTableViewSection.swift new file mode 100644 index 0000000..a4e810f --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsTableViewSection.swift @@ -0,0 +1,84 @@ +// +// SearchFactsTableViewSection.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/26/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import RxDataSources + +enum SearchFactsTableViewItem { + case SuggestionsTableViewItem(suggestions: [FactCategoryViewModel]) + case PastSearchTableViewItem(pastSearch: PastSearchViewModel) +} + +extension SearchFactsTableViewItem { + var text: String { + switch self { + case .SuggestionsTableViewItem: + return "" + case .PastSearchTableViewItem(let pastSearch): + return pastSearch.text + } + } +} + +enum SearchFactsTableViewSection { + case SuggestionsSection(items: [SearchFactsTableViewItem]) + case PastSearchesSection(items: [SearchFactsTableViewItem]) +} + +extension SearchFactsTableViewSection: SectionModelType { + typealias Item = SearchFactsTableViewItem + + var header: String { + switch self { + case .SuggestionsSection: + return L10n.SearchFacts.Sections.suggestions + case .PastSearchesSection: + return L10n.SearchFacts.Sections.pastSearches + } + } + + var items: [SearchFactsTableViewItem] { + switch self { + case .SuggestionsSection(let items): + return items + case .PastSearchesSection(let items): + return items + } + } + + var isEmpty: Bool { + switch self { + case .SuggestionsSection(let items): + switch items.first { + case .SuggestionsTableViewItem(let suggestions): + return suggestions.isEmpty + default: + return true + } + case .PastSearchesSection(let items): + return items.isEmpty + } + } + + var count: Int { + switch self { + case .SuggestionsSection(let items): + switch items.first { + case .SuggestionsTableViewItem(let suggestions): + return suggestions.count + default: + return 0 + } + case .PastSearchesSection(let items): + return items.count + } + } + + init(original: Self, items: [Self.Item]) { + self = original + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewController.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewController.swift new file mode 100644 index 0000000..ed4c827 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewController.swift @@ -0,0 +1,147 @@ +// +// SearchFactsViewController.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/13/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RxSwift +import RxDataSources + +final class SearchFactsViewController: UIViewController { + + var viewModel: SearchFactsViewModel! + + let disposeBag = DisposeBag() + + private lazy var itemsDataSource = RxTableViewSectionedReloadDataSource( + configureCell: { [weak self] dataSource, tableView, indexPath, _ -> UITableViewCell in + + switch dataSource[indexPath] { + case .SuggestionsTableViewItem(let suggestions): + guard let searchFactsViewModel = self?.viewModel else { return UITableViewCell() } + + let cell = tableView.dequeueReusableCell(cell: SuggestionsCell.self, indexPath: indexPath) + + let viewModel = SuggestionsViewModel(suggestions: suggestions) + cell.viewModel = viewModel + + viewModel.outputs.didSelectSuggestion + .bind(to: searchFactsViewModel.inputs.selectItem) + .disposed(by: cell.disposeBag) + + return cell + case .PastSearchTableViewItem(let pastSearch): + let cell = tableView.dequeueReusableCell(cell: PastSearchCell.self, indexPath: indexPath) + cell.setup(pastSearch) + return cell + } + + }, titleForHeaderInSection: { dataSource, index in + return dataSource.sectionModels[index].header + } + ) + + lazy var tableView: UITableView = { + let tableView = UITableView() + + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.accessibilityIdentifier = "itemsTableView" + tableView.tableFooterView = UIView() + + return tableView + }() + + lazy var cancelButton: UIBarButtonItem = { + let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: nil, action: nil) + cancelButton.accessibilityIdentifier = "cancelButton" + return cancelButton + }() + + lazy var searchController: UISearchController = { + let searchController = UISearchController(searchResultsController: nil) + + searchController.obscuresBackgroundDuringPresentation = false + searchController.searchBar.enablesReturnKeyAutomatically = true + searchController.searchBar.returnKeyType = .search + + return searchController + }() + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + setupNavigationBar() + setupBindings() + setupTableView() + } + + private func setupView() { + view.backgroundColor = .systemBackground + view.accessibilityIdentifier = "searchFactsView" + } + + private func setupTableView() { + view.addSubview(tableView) + + tableView.backgroundColor = .systemBackground + tableView.rowHeight = UITableView.automaticDimension + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + tableView.register(SuggestionsCell.self) + tableView.register(PastSearchCell.self) + } + + private func setupNavigationBar() { + navigationItem.hidesSearchBarWhenScrolling = false + navigationItem.searchController = searchController + navigationItem.leftBarButtonItem = cancelButton + navigationItem.title = L10n.SearchFacts.title + } + + private func setupBindings() { + rx.viewWillAppear + .bind(to: viewModel.inputs.viewWillAppear) + .disposed(by: disposeBag) + + cancelButton.rx.tap + .bind(to: viewModel.inputs.cancel) + .disposed(by: disposeBag) + + searchController.searchBar.rx.text + .compactMap { $0 } + .bind(to: viewModel.inputs.searchTerm) + .disposed(by: disposeBag) + + searchController.searchBar.rx.textDidEndEditing + .bind(to: viewModel.inputs.searchAction) + .disposed(by: disposeBag) + + viewModel.outputs.items + .bind(to: tableView.rx.items(dataSource: itemsDataSource)) + .disposed(by: disposeBag) + + let pastSearchSelected = tableView.rx + .modelSelected(SearchFactsTableViewItem.self) + .asObservable() + + pastSearchSelected + .compactMap { $0.text } + .bind(to: viewModel.inputs.selectItem) + .disposed(by: disposeBag) + + pastSearchSelected + .mapToVoid() + .bind(to: viewModel.inputs.searchAction) + .disposed(by: disposeBag) + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewModel.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewModel.swift new file mode 100644 index 0000000..7ccc400 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewModel.swift @@ -0,0 +1,118 @@ +// +// SearchFactsViewModel.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/13/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RxDataSources +import RxSwift + +typealias PastSearchesSectionModel = AnimatableSectionModel + +protocol SearchFactsViewModelInputs { + // Call when search facts scene is cancelled + var cancel: AnyObserver { get } + + // Call when search term changes + var searchTerm: AnyObserver { get } + + // Call when a search is started (textDidEndEditing) + var searchAction: AnyObserver { get } + + // Call when view will appear to load categories and pastSearches + var viewWillAppear: AnyObserver { get } + + // Call when a item is selected to start a new search + var selectItem: AnyObserver { get } +} + +protocol SearchFactsViewModelOutputs { + // Emmits an event to SearchFacts coordinator dismiss SearchFacts Scene + var didCancel: Observable { get } + + // Emmits an string to start a new search when a term is sent by searchAction + var didSearchFacts: Observable { get } + + // Emmits an string to start a new search when a item is selected + var didSelectItem: Observable { get } + + // Emmits an array of SearchFacts TableView sections to bind on tableView + var items: Observable<[SearchFactsTableViewSection]> { get } +} + +final class SearchFactsViewModel: SearchFactsViewModelInputs, SearchFactsViewModelOutputs { + + var inputs: SearchFactsViewModelInputs { self } + + var outputs: SearchFactsViewModelOutputs { self } + + // MARK: - Inputs + + var cancel: AnyObserver + + var searchTerm: AnyObserver + + var searchAction: AnyObserver + + var viewWillAppear: AnyObserver + + var selectItem: AnyObserver + + // MARK: - Outputs + + var didCancel: Observable + + var didSearchFacts: Observable + + var didSelectItem: Observable + + var items: Observable<[SearchFactsTableViewSection]> + + init(factsService: FactsServiceType = FactsService()) { + let cancelSubject = PublishSubject() + self.cancel = cancelSubject.asObserver() + self.didCancel = cancelSubject.asObservable() + + let searchTermSubject = BehaviorSubject(value: "") + self.searchTerm = searchTermSubject.asObserver() + + let searchActionSubject = PublishSubject() + self.searchAction = searchActionSubject.asObserver() + + self.didSearchFacts = searchActionSubject + .withLatestFrom(searchTermSubject) + .filter { !$0.isEmpty } + + let selectItemSubject = BehaviorSubject(value: "") + self.selectItem = selectItemSubject.asObserver() + + self.didSelectItem = selectItemSubject + .filter { !$0.isEmpty } + + let viewWillAppearSubject = PublishSubject() + self.viewWillAppear = viewWillAppearSubject.asObserver() + + let categories = viewWillAppearSubject + .flatMapLatest { factsService.retrieveCategories() } + .map { Array($0.shuffled().prefix(8)) } + + let suggestions = categories + .map { $0.map { FactCategoryViewModel(category: $0) } } + .map { [SearchFactsTableViewItem.SuggestionsTableViewItem(suggestions: $0)] } + .map { suggestions -> SearchFactsTableViewSection in .SuggestionsSection(items: suggestions) } + + let pastSearches = viewWillAppearSubject + .flatMapLatest { factsService.retrievePastSearches() } + .map { $0.map { PastSearchViewModel(text: $0) } } + .map { $0.map { SearchFactsTableViewItem.PastSearchTableViewItem(pastSearch: $0) } } + .map { pastSearches -> SearchFactsTableViewSection in .PastSearchesSection(items: pastSearches)} + + self.items = Observable.combineLatest(suggestions, pastSearches) { suggestions, pastSearches in + [suggestions, pastSearches] + } + .map { $0.filter { !$0.isEmpty } } + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryCell.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryCell.swift new file mode 100644 index 0000000..eec5857 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryCell.swift @@ -0,0 +1,44 @@ +// +// FactCategoryCell.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit + +final class FactCategoryCell: UICollectionViewCell { + + private lazy var categoryView: CategoryView = { + let categoryView = CategoryView() + categoryView.translatesAutoresizingMaskIntoConstraints = false + return categoryView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + setupView() + + isAccessibilityElement = true + accessibilityIdentifier = "factCategoryCell" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setup(_ factCategory: FactCategoryViewModel) { + categoryView.label.text = factCategory.text.uppercased() + categoryView.label.font = .preferredFont(forTextStyle: .headline) + } + + func setupView() { + contentView.addSubview(categoryView) + NSLayoutConstraint.activate([ + categoryView.widthAnchor.constraint(equalTo: contentView.widthAnchor), + categoryView.heightAnchor.constraint(equalTo: contentView.heightAnchor) + ]) + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryViewModel.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryViewModel.swift new file mode 100644 index 0000000..3c15e44 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryViewModel.swift @@ -0,0 +1,32 @@ +// +// FactCategoryViewModel.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RxDataSources + +final class FactCategoryViewModel { + let category: FactCategory + let text: String + + init(category: FactCategory) { + self.category = category + self.text = category.text + } +} + +extension FactCategoryViewModel: IdentifiableType { + var identity: String { + category.text + } +} + +extension FactCategoryViewModel: Equatable { + static func == (lhs: FactCategoryViewModel, rhs: FactCategoryViewModel) -> Bool { + return lhs.category == rhs.category + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsCell.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsCell.swift new file mode 100644 index 0000000..4267534 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsCell.swift @@ -0,0 +1,103 @@ +// +// SuggestionsCell.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/26/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RxSwift +import RxCocoa +import RxDataSources + +final class SuggestionsCell: UITableViewCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + var disposeBag = DisposeBag() + + override func prepareForReuse() { + disposeBag = DisposeBag() + super.prepareForReuse() + } + + private lazy var suggestionsDataSource = RxCollectionViewSectionedReloadDataSource( + configureCell: { _, collectionView, indexPath, category -> UICollectionViewCell in + let cell = collectionView.dequeueReusableCell(cell: FactCategoryCell.self, indexPath: indexPath) + cell.setup(category) + return cell + } + ) + + var viewModel: SuggestionsViewModel! { + didSet { + self.setupBindings() + } + } + + lazy var collectionView: DynamicHeightCollectionView = { + let insets: CGFloat = 16 + + let suggestionsViewFlowLayout = SuggestionsViewFlowLayout() + let collectionView = DynamicHeightCollectionView(frame: .zero, collectionViewLayout: suggestionsViewFlowLayout) + + suggestionsViewFlowLayout.scrollDirection = .vertical + suggestionsViewFlowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize + suggestionsViewFlowLayout.sectionInset = UIEdgeInsets(top: insets, left: insets, bottom: insets, right: insets) + + collectionView.isScrollEnabled = false + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.register(FactCategoryCell.self) + + return collectionView + }() + + private func setupView() { + collectionView.backgroundColor = .systemBackground + + contentView.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.widthAnchor.constraint(equalTo: contentView.widthAnchor), + collectionView.heightAnchor.constraint(equalTo: contentView.heightAnchor) + ]) + } + + private func setupBindings() { + collectionView.rx + .setDelegate(self) + .disposed(by: disposeBag) + + viewModel.outputs.suggestions + .observeOn(MainScheduler.instance) + .bind(to: collectionView.rx.items(dataSource: suggestionsDataSource)) + .disposed(by: disposeBag) + + let suggestionSelected = collectionView.rx + .modelSelected(FactCategoryViewModel.self) + .asObservable() + + suggestionSelected + .compactMap { $0.text } + .bind(to: viewModel.inputs.selectSuggestion) + .disposed(by: disposeBag) + } +} + +extension SuggestionsCell: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath) -> CGSize { + let cell = FactCategoryCell() + let item = suggestionsDataSource.sectionModels[indexPath.section].items[indexPath.row] + cell.setup(item) + return cell.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + } +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsViewFlowLayout.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsViewFlowLayout.swift new file mode 100644 index 0000000..aa6b5ca --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsViewFlowLayout.swift @@ -0,0 +1,32 @@ +// +// SuggestionsViewFlowLayout.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/21/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit + +final class SuggestionsViewFlowLayout: UICollectionViewFlowLayout { + + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + let attributes = super.layoutAttributesForElements(in: rect) + + var leftMargin = sectionInset.left + var maxY: CGFloat = -1.0 + attributes?.forEach { layoutAttribute in + if layoutAttribute.representedElementCategory == .cell { + if layoutAttribute.frame.origin.y >= maxY { + leftMargin = sectionInset.left + } + layoutAttribute.frame.origin.x = leftMargin + leftMargin += layoutAttribute.frame.width + minimumInteritemSpacing + maxY = max(layoutAttribute.frame.maxY, maxY) + } + } + + return attributes + } + +} diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsViewModel.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsViewModel.swift new file mode 100644 index 0000000..b8875c3 --- /dev/null +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsViewModel.swift @@ -0,0 +1,56 @@ +// +// SuggestionsViewModel.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/26/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import RxSwift +import RxDataSources + +typealias SuggestionsSectionModel = AnimatableSectionModel + +protocol SuggestionsViewModelInputs { + // Call when a suggestion is selected to start a new search + var selectSuggestion: AnyObserver { get } +} + +protocol SuggestionsViewModelOutputs { + // Emmits an array of suggestions to bind on tableView + var suggestions: Observable<[SuggestionsSectionModel]> { get } + + // Emmits an string of a selected suggestion + var didSelectSuggestion: Observable { get } +} + +struct SuggestionsViewModel: SuggestionsViewModelInputs, SuggestionsViewModelOutputs { + + var inputs: SuggestionsViewModelInputs { self } + + var outputs: SuggestionsViewModelOutputs { self } + + // MARK: - Inputs + + var selectSuggestion: AnyObserver + + // MARK: - Outputs + + var suggestions: Observable<[SuggestionsSectionModel]> + + var didSelectSuggestion: Observable + + init(suggestions: [FactCategoryViewModel]) { + let suggestionsSubject = BehaviorSubject<[SuggestionsSectionModel]>(value: []) + self.suggestions = suggestionsSubject.asObserver() + + suggestionsSubject.onNext([SuggestionsSectionModel(model: "", items: suggestions)]) + + let selectSuggestionSubject = BehaviorSubject(value: "") + self.selectSuggestion = selectSuggestionSubject.asObserver() + + self.didSelectSuggestion = selectSuggestionSubject + .filter { !$0.isEmpty } + .map { $0.capitalized } + } +} diff --git a/Chuck Norris Facts/App/Views/CategoryView.swift b/Chuck Norris Facts/App/Views/CategoryView.swift new file mode 100644 index 0000000..cb35179 --- /dev/null +++ b/Chuck Norris Facts/App/Views/CategoryView.swift @@ -0,0 +1,48 @@ +// +// CategoryView.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/24/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit + +final class CategoryView: UIView { + + lazy var label: UILabel = { + let label = UILabel() + + label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byTruncatingTail + label.font = .preferredFont(forTextStyle: .headline) + label.textColor = .white + label.numberOfLines = 1 + + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setupView() { + let cornerRadius: CGFloat = 4 + let padding: CGFloat = 4 + + layer.cornerRadius = cornerRadius + backgroundColor = .systemBlue + + addSubview(label) + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding).isActive = true + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding).isActive = true + label.topAnchor.constraint(equalTo: topAnchor, constant: padding).isActive = true + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding).isActive = true + } +} diff --git a/Chuck Norris Facts/AppDelegate.swift b/Chuck Norris Facts/AppDelegate.swift deleted file mode 100644 index babef19..0000000 --- a/Chuck Norris Facts/AppDelegate.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// AppDelegate.swift -// Chuck Norris Facts -// -// Created by Djorkaeff Alexandre Vilela Pereira on 10/9/20. -// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. -// - -import UIKit - -@UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { - - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - return true - } - - // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } - - -} - diff --git a/Chuck Norris Facts/Base.lproj/Main.storyboard b/Chuck Norris Facts/Base.lproj/Main.storyboard deleted file mode 100644 index 25a7638..0000000 --- a/Chuck Norris Facts/Base.lproj/Main.storyboard +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Chuck Norris Facts/Core/API/APIError.swift b/Chuck Norris Facts/Core/API/APIError.swift new file mode 100644 index 0000000..8d9758e --- /dev/null +++ b/Chuck Norris Facts/Core/API/APIError.swift @@ -0,0 +1,74 @@ +// +// APIError.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/30/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +protocol APIErrorType: LocalizedError { + var code: Int { get } + var message: String { get } +} + +// A type representing possible errors API can throw. +enum APIError: Swift.Error { + + // Indicates that an Unknown error happened. + case unknown(Swift.Error?) + + // Indicates data was not received. + case mapping(Swift.Error?) + + // Indicates that user doesn't have a network connection. + case noConnection + + // Indicates a response failed with an invalid HTTP status code. + case statusCode(Int) + + // Indicates that the network response was not convertible to HTTPURLResponse. + case connectionError +} + +extension APIError: APIErrorType { + + // Code for each error type. + var code: Int { + switch self { + case .unknown: + return 0 + case .mapping: + return 1 + case .noConnection: + return 2 + case .statusCode: + return 3 + case .connectionError: + return 4 + } + } + + // A description about the error. + var message: String { + switch self { + case .unknown(let error): + return error?.localizedDescription ?? "Something unexpected happened." + case .mapping(let error): + return error?.localizedDescription ?? "Error while trying to map response." + case .noConnection: + return "Internet Connection appears to be offline." + case .statusCode(let code): + return "Chuck Norris API returned \(code) statusCode." + case .connectionError: + return "Something unexpected happened with your connection." + } + } +} + +extension APIError: Equatable { + static func == (lhs: APIError, rhs: APIError) -> Bool { + return lhs.code == rhs.code + } +} diff --git a/Chuck Norris Facts/Core/API/APIProvider.swift b/Chuck Norris Facts/Core/API/APIProvider.swift new file mode 100644 index 0000000..2cb477c --- /dev/null +++ b/Chuck Norris Facts/Core/API/APIProvider.swift @@ -0,0 +1,80 @@ +// +// APIProvider.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/30/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +// A protocol representing a minimal interface for a APIProvider. +protocol APIProviderType: AnyObject { + + // Completion of a request make by a provider. + typealias Completion = (_ result: Result) -> Void + + // Associated type of an APITarget. + associatedtype Target: APITarget + + // Designated request-making method. + func request(_ target: Target, completion: @escaping Completion) -> URLSessionDataTask? +} + +class APIProvider: APIProviderType { + + // Closure that defines the urlRequest for the provider. + typealias RequestClosure = (Target) -> URLRequest + + // A closure responsible for mapping a `APITarget` to an `URLRequest`. + let requestClosure: RequestClosure + + private let urlSession: URLSession + + init( + urlSession: URLSession = URLSession.shared, + requestClosure: @escaping RequestClosure = { $0.urlRequest() } + ) { + self.urlSession = urlSession + self.requestClosure = requestClosure + } + + // Designated request-making method. + func request(_ target: Target, completion: @escaping Completion) -> URLSessionDataTask? { + + let request = requestClosure(target) + + // Check if request has some sampleData + if let sampleData = target.sampleData { + completion(.success(APIResponse(statusCode: 200, data: sampleData))) + return nil + } + + let task = urlSession.dataTask(with: request) { (data, response, error) in + // Check if error is not connected to internet + if let error = error as NSError?, error.code == NSURLErrorNotConnectedToInternet { + completion(.failure(.noConnection)) + return + } + + // Check if there is an error + if let error = error { + completion(.failure(.unknown(error))) + return + } + + // Check if response is a HTTPURLResponse + guard let response = response as? HTTPURLResponse else { + completion(.failure(.connectionError)) + return + } + + // Complete with an APIResponse + completion(.success(APIResponse(statusCode: response.statusCode, data: data))) + } + + task.resume() + + return task + } +} diff --git a/Chuck Norris Facts/Core/API/APIResponse.swift b/Chuck Norris Facts/Core/API/APIResponse.swift new file mode 100644 index 0000000..1f5cd8a --- /dev/null +++ b/Chuck Norris Facts/Core/API/APIResponse.swift @@ -0,0 +1,27 @@ +// +// APIResponse.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/30/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +struct APIResponse { + let statusCode: Int + let data: Data? +} + +extension APIResponse { + func filter(statusCodes: R) throws -> APIResponse where R.Bound == Int { + guard statusCodes.contains(statusCode) else { + throw APIError.statusCode(statusCode) + } + return self + } + + func filterSuccessfulStatusCodes() throws -> APIResponse { + return try filter(statusCodes: 200...299) + } +} diff --git a/Chuck Norris Facts/Core/API/APITarget.swift b/Chuck Norris Facts/Core/API/APITarget.swift new file mode 100644 index 0000000..5459f8e --- /dev/null +++ b/Chuck Norris Facts/Core/API/APITarget.swift @@ -0,0 +1,62 @@ +// +// APITarget.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/30/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +protocol APITarget { + + // The target's base `URL`. + var baseURL: URL { get } + + // The path to be appended to `baseURL` to form the full `URL`. + var path: String { get } + + // The HTTP method used in the request. + var method: HTTPMethod { get } + + // The headers to be used in the request. + var headers: [String: String]? { get } + + // Provides stub data for use in testing. + var sampleData: Data? { get } + + // The type of HTTP task to be performed. + var task: HTTPTask { get } +} + +extension APITarget { + + // Returns the `Endpoint` converted to a `URLRequest` if valid. Throws an error otherwise. + func urlRequest() -> URLRequest { + + var url: URL + let targetPath = self.path + if targetPath.isEmpty { + url = baseURL + } else { + url = baseURL.appendingPathComponent(targetPath) + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.allHTTPHeaderFields = headers + + switch task { + case .requestPlain: + return request + case .requestParameters(let parameters): + return request.encoded(parameters: parameters) + } + } +} + +extension APITarget { + + // Provides stub data for use in testing. Default is `Data()`. + var sampleData: Data? { Data() } +} diff --git a/Chuck Norris Facts/Core/API/HTTP/HTTPMethod.swift b/Chuck Norris Facts/Core/API/HTTP/HTTPMethod.swift new file mode 100644 index 0000000..1191d46 --- /dev/null +++ b/Chuck Norris Facts/Core/API/HTTP/HTTPMethod.swift @@ -0,0 +1,14 @@ +// +// HTTPMethod.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/30/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +enum HTTPMethod: String { + case get = "GET" + case post = "POST" +} diff --git a/Chuck Norris Facts/Core/API/HTTP/HTTPTask.swift b/Chuck Norris Facts/Core/API/HTTP/HTTPTask.swift new file mode 100644 index 0000000..a9d58a9 --- /dev/null +++ b/Chuck Norris Facts/Core/API/HTTP/HTTPTask.swift @@ -0,0 +1,19 @@ +// +// HTTPTask.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/31/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +// Represents an HTTP task. +enum HTTPTask { + + // A request with no additional data. + case requestPlain + + // A requests body set with encoded parameters. + case requestParameters(parameters: [String: Any]) +} diff --git a/Chuck Norris Facts/Core/Data/Models/Fact.swift b/Chuck Norris Facts/Core/Data/Models/Fact.swift new file mode 100644 index 0000000..1c59215 --- /dev/null +++ b/Chuck Norris Facts/Core/Data/Models/Fact.swift @@ -0,0 +1,31 @@ +// +// Fact.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +struct Fact: Decodable { + let id: String + let value: String + let url: String? + let iconUrl: String + let categories: [FactCategory] + + init(id: String, value: String, url: String?, iconUrl: String, categories: [FactCategory]) { + self.id = id + self.value = value + self.url = url + self.iconUrl = iconUrl + self.categories = categories + } +} + +extension Fact: Equatable { + static func == (lhs: Fact, rhs: Fact) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Chuck Norris Facts/Core/Data/Models/FactCategory.swift b/Chuck Norris Facts/Core/Data/Models/FactCategory.swift new file mode 100644 index 0000000..f9ed3e2 --- /dev/null +++ b/Chuck Norris Facts/Core/Data/Models/FactCategory.swift @@ -0,0 +1,27 @@ +// +// FactCategory.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +struct FactCategory: Decodable { + let text: String + + init(text: String) { + self.text = text + } + + init(from decoder: Decoder) throws { + self.text = try decoder.singleValueContainer().decode(String.self) + } +} + +extension FactCategory: Equatable { + static func == (lhs: FactCategory, rhs: FactCategory) -> Bool { + lhs.text == rhs.text + } +} diff --git a/Chuck Norris Facts/Core/Data/Networking/FactsAPI.swift b/Chuck Norris Facts/Core/Data/Networking/FactsAPI.swift new file mode 100644 index 0000000..a05ba11 --- /dev/null +++ b/Chuck Norris Facts/Core/Data/Networking/FactsAPI.swift @@ -0,0 +1,66 @@ +// +// FactsService.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +enum FactsAPI { + case searchFacts(searchTerm: String) + case getCategories +} + +extension FactsAPI: APITarget { + var baseURL: URL { + return URL(string: "https://api.chucknorris.io/jokes")! + } + + var path: String { + switch self { + case .searchFacts: + return "/search" + case .getCategories: + return "/categories" + } + } + + var method: HTTPMethod { + switch self { + case .searchFacts, .getCategories: + return .get + } + } + + var task: HTTPTask { + switch self { + case .searchFacts(let searchTerm): + return .requestParameters(parameters: ["query": searchTerm]) + case .getCategories: + return .requestPlain + } + } + + var headers: [String: String]? { + return ["Content-type": "application/json"] + } + + var sampleData: Data? { + if LaunchArgument.check(.mockHttp) { + switch self { + case .getCategories: + return Data.stub("get-categories") + case .searchFacts: + return Data.stub("search-facts") + } + } + + if LaunchArgument.check(.mockHttpError) { + return Data() + } + + return nil + } +} diff --git a/Chuck Norris Facts/Core/Data/Networking/Responses/SearchFactsResponse.swift b/Chuck Norris Facts/Core/Data/Networking/Responses/SearchFactsResponse.swift new file mode 100644 index 0000000..0a1fc48 --- /dev/null +++ b/Chuck Norris Facts/Core/Data/Networking/Responses/SearchFactsResponse.swift @@ -0,0 +1,19 @@ +// +// SearchFactsResponse.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +struct SearchFactsResponse: Decodable { + let total: Int + let facts: [Fact] + + enum CodingKeys: String, CodingKey { + case total + case facts = "result" + } +} diff --git a/Chuck Norris Facts/Core/Data/Services/FactsService.swift b/Chuck Norris Facts/Core/Data/Services/FactsService.swift new file mode 100644 index 0000000..b5e0372 --- /dev/null +++ b/Chuck Norris Facts/Core/Data/Services/FactsService.swift @@ -0,0 +1,80 @@ +// +// FactsService.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import RxSwift + +protocol FactsServiceType { + + // Search Facts on Chuck Norris API + func searchFacts(searchTerm: String) -> Observable<[Fact]> + + // Sync local stored Categories with Chuck Norris API Categories + func syncCategories() -> Observable + + // Retrieve local stored Categories + func retrieveCategories() -> Observable<[FactCategory]> + + // Retrieve local stored Past Searches + func retrievePastSearches() -> Observable<[String]> +} + +struct FactsService: FactsServiceType { + + private var provider: APIProvider + private var storage: FactsStorageType + private var scheduler: SchedulerType? + + init( + provider: APIProvider = APIProvider(), + storage: FactsStorageType = FactsStorage(), + scheduler: SchedulerType? = nil + ) { + self.provider = provider + self.storage = storage + self.scheduler = scheduler + } + + func searchFacts(searchTerm: String) -> Observable<[Fact]> { + guard !searchTerm.isEmpty else { return .just([]) } + + return provider.rx + .request(.searchFacts(searchTerm: searchTerm)) + .asObservable() + .filterSuccessfulStatusCodes() + .observeOn(self.scheduler ?? MainScheduler.asyncInstance) + .map(SearchFactsResponse.self, using: JSON.decoder) + .map { $0.facts } + .do(onNext: { _ in + self.storage.storeSearch(searchTerm: searchTerm) + }) + } + + func syncCategories() -> Observable { + storage.retrieveCategories() + .flatMapLatest { categories -> Observable in + guard categories.isEmpty else { return Observable.just(()) } + + return self.provider.rx + .request(.getCategories) + .asObservable() + .filterSuccessfulStatusCodes() + .observeOn(self.scheduler ?? MainScheduler.asyncInstance) + .map([FactCategory].self, using: JSON.decoder) + .map { self.storage.storeCategories($0) } + .mapToVoid() + } + } + + func retrieveCategories() -> Observable<[FactCategory]> { + storage.retrieveCategories() + } + + func retrievePastSearches() -> Observable<[String]> { + storage.retrieveSearches() + } +} diff --git a/Chuck Norris Facts/Core/Data/Storage/Entities/FactCategoryEntity.swift b/Chuck Norris Facts/Core/Data/Storage/Entities/FactCategoryEntity.swift new file mode 100644 index 0000000..d0156d6 --- /dev/null +++ b/Chuck Norris Facts/Core/Data/Storage/Entities/FactCategoryEntity.swift @@ -0,0 +1,26 @@ +// +// FactCategoryEntity.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RealmSwift + +class FactCategoryEntity: Object { + @objc dynamic var text = "" + + override static func primaryKey() -> String? { + "text" + } + + convenience init(category: FactCategory) { + self.init(value: ["text": category.text]) + } + + var item: FactCategory { + FactCategory(text: text) + } +} diff --git a/Chuck Norris Facts/Core/Data/Storage/Entities/SearchEntity.swift b/Chuck Norris Facts/Core/Data/Storage/Entities/SearchEntity.swift new file mode 100644 index 0000000..9e03521 --- /dev/null +++ b/Chuck Norris Facts/Core/Data/Storage/Entities/SearchEntity.swift @@ -0,0 +1,23 @@ +// +// SearchEntity.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/25/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RealmSwift + +class SearchEntity: Object { + @objc dynamic var searchTerm = "" + @objc dynamic var updatedAt = Date() + + override static func primaryKey() -> String? { + "searchTerm" + } + + convenience init(searchTerm: String) { + self.init(value: ["searchTerm": searchTerm]) + } +} diff --git a/Chuck Norris Facts/Core/Data/Storage/FactsStorage.swift b/Chuck Norris Facts/Core/Data/Storage/FactsStorage.swift new file mode 100644 index 0000000..b80f0a7 --- /dev/null +++ b/Chuck Norris Facts/Core/Data/Storage/FactsStorage.swift @@ -0,0 +1,58 @@ +// +// FactsStorage.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RxSwift +import RealmSwift +import RxRealm + +protocol FactsStorageType { + // Store a list of categories + func storeCategories(_ categories: [FactCategory]) + + // Retrieve all local stored categories + func retrieveCategories() -> Observable<[FactCategory]> + + // Store a search and it's result + func storeSearch(searchTerm: String) + + // Retrieve all past searches terms + func retrieveSearches() -> Observable<[String]> +} + +final class FactsStorage: FactsStorageType { + private let realm: Realm! + + init(realm: Realm? = nil) { + self.realm = realm ?? (try? Realm()) + } + + func storeCategories(_ categories: [FactCategory]) { + try? realm.write { + let entities = categories.map(FactCategoryEntity.init) + self.realm.add(entities, update: .modified) + } + } + + func retrieveCategories() -> Observable<[FactCategory]> { + let entities = realm.objects(FactCategoryEntity.self) + return Observable.collection(from: entities).map { $0.map { $0.item } } + } + + func storeSearch(searchTerm: String) { + try? realm.write { + let entity = SearchEntity(searchTerm: searchTerm) + self.realm.add(entity, update: .modified) + } + } + + func retrieveSearches() -> Observable<[String]> { + let entities = realm.objects(SearchEntity.self).sorted(byKeyPath: "updatedAt", ascending: false) + return Observable.collection(from: entities).map { $0.map { $0.searchTerm } } + } +} diff --git a/Chuck Norris Facts/Core/Extensions/API+Rx.swift b/Chuck Norris Facts/Core/Extensions/API+Rx.swift new file mode 100644 index 0000000..c58d06d --- /dev/null +++ b/Chuck Norris Facts/Core/Extensions/API+Rx.swift @@ -0,0 +1,54 @@ +// +// API+Rx.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/30/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RxSwift + +extension APIProvider: ReactiveCompatible {} + +extension Reactive where Base: APIProviderType { + + func request(_ token: Base.Target, callbackQueue: DispatchQueue? = nil) -> Single { + return Single.create { [weak base] single in + let cancellableToken = base?.request(token) { result in + switch result { + case let .success(response): + single(.success(response)) + case let .failure(error): + single(.error(error)) + } + } + + return Disposables.create { + cancellableToken?.cancel() + } + } + } +} + +extension ObservableType where Element == APIResponse { + // Maps received data into a Decodable object. If the conversion fails, throw an APIError. + func map(_ type: D.Type, using decoder: JSONDecoder = JSON.decoder) -> Observable { + flatMap { response -> Observable in + do { + guard let data = response.data else { + throw APIError.mapping(nil) + } + + return Observable.just(try decoder.decode(D.self, from: data)) + } catch let error { + throw APIError.mapping(error) + } + } + } + + // Filters out responses where `statusCode` falls within the range 200 - 299. + func filterSuccessfulStatusCodes() -> Observable { + return flatMap { Observable.just(try $0.filterSuccessfulStatusCodes()) } + } +} diff --git a/Chuck Norris Facts/Core/Extensions/Data+Stub.swift b/Chuck Norris Facts/Core/Extensions/Data+Stub.swift new file mode 100644 index 0000000..c3ddfa8 --- /dev/null +++ b/Chuck Norris Facts/Core/Extensions/Data+Stub.swift @@ -0,0 +1,39 @@ +// +// Data+Stub.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/21/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +extension Data { + + static func stub(_ resource: String) -> Data? { + guard let url = Bundle.main.url(forResource: resource, withExtension: ".json") else { + return nil + } + + do { + let data = try Data(contentsOf: url) + return data + } catch { + return nil + } + } + + static func stub(_ resource: String, type: T.Type, decoder: JSONDecoder = JSON.decoder) -> T? { + guard let url = Bundle.main.url(forResource: resource, withExtension: ".json") else { + return nil + } + + do { + let data = try Data(contentsOf: url) + let stub = try decoder.decode(type, from: data) + return stub + } catch { + return nil + } + } +} diff --git a/Chuck Norris Facts/Core/Extensions/RxSwift+Extensions.swift b/Chuck Norris Facts/Core/Extensions/RxSwift+Extensions.swift new file mode 100644 index 0000000..d015480 --- /dev/null +++ b/Chuck Norris Facts/Core/Extensions/RxSwift+Extensions.swift @@ -0,0 +1,26 @@ +// +// RxSwift+Extensions.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 11/1/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RxSwift + +extension ObservableType { + func mapToVoid() -> Observable { + map { _ in () } + } +} + +extension ObservableType where Element: EventConvertible { + func elements() -> Observable { + compactMap { $0.event.element } + } + + func errors() -> Observable { + compactMap { $0.event.error } + } +} diff --git a/Chuck Norris Facts/Core/Extensions/UICollectionView+Extensions.swift b/Chuck Norris Facts/Core/Extensions/UICollectionView+Extensions.swift new file mode 100644 index 0000000..3a36910 --- /dev/null +++ b/Chuck Norris Facts/Core/Extensions/UICollectionView+Extensions.swift @@ -0,0 +1,19 @@ +// +// UICollectionView+Extensions.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/28/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit + +extension UICollectionView { + func register(_ cell: UICollectionViewCell.Type) { + register(cell, forCellWithReuseIdentifier: String(describing: cell.self)) + } + + func dequeueReusableCell(cell: T.Type, indexPath: IndexPath) -> T { + dequeueReusableCell(withReuseIdentifier: String(describing: T.self), for: indexPath) as? T ?? T() + } +} diff --git a/Chuck Norris Facts/Core/Extensions/UITableView+Extensions.swift b/Chuck Norris Facts/Core/Extensions/UITableView+Extensions.swift new file mode 100644 index 0000000..c7223a7 --- /dev/null +++ b/Chuck Norris Facts/Core/Extensions/UITableView+Extensions.swift @@ -0,0 +1,19 @@ +// +// UITableView+Extensions.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/28/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit + +extension UITableView { + func register(_ cell: UITableViewCell.Type) { + register(cell, forCellReuseIdentifier: String(describing: cell.self)) + } + + func dequeueReusableCell(cell: T.Type, indexPath: IndexPath) -> T { + dequeueReusableCell(withIdentifier: String(describing: T.self), for: indexPath) as? T ?? T() + } +} diff --git a/Chuck Norris Facts/Core/Extensions/UIViewController+Rx.swift b/Chuck Norris Facts/Core/Extensions/UIViewController+Rx.swift new file mode 100644 index 0000000..76a12ea --- /dev/null +++ b/Chuck Norris Facts/Core/Extensions/UIViewController+Rx.swift @@ -0,0 +1,20 @@ +// +// UIViewController+Rx.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/12/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit +import RxSwift + +extension Reactive where Base: UIViewController { + var viewDidAppear: Observable { + sentMessage(#selector(Base.viewDidAppear(_:))).mapToVoid() + } + + var viewWillAppear: Observable { + sentMessage(#selector(Base.viewWillAppear(_:))).mapToVoid() + } +} diff --git a/Chuck Norris Facts/Core/Extensions/URLRequest+Encoded.swift b/Chuck Norris Facts/Core/Extensions/URLRequest+Encoded.swift new file mode 100644 index 0000000..3bd1573 --- /dev/null +++ b/Chuck Norris Facts/Core/Extensions/URLRequest+Encoded.swift @@ -0,0 +1,25 @@ +// +// URLRequest+Encoded.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/30/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +extension URLRequest { + + // Encode an URL request into a new URLRequest with parameters + func encoded(parameters: [String: Any]?) -> URLRequest { + guard let url = url else { return self } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.query = parameters? + .compactMap { "\($0.key)=\($0.value)" } + .joined(separator: "&") + .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + + return URLRequest(url: components?.url ?? url) + } +} diff --git a/Chuck Norris Facts/Core/Library/ActivityIndicator.swift b/Chuck Norris Facts/Core/Library/ActivityIndicator.swift new file mode 100644 index 0000000..7bfed22 --- /dev/null +++ b/Chuck Norris Facts/Core/Library/ActivityIndicator.swift @@ -0,0 +1,80 @@ +// +// ActivityIndicator.swift +// RxExample +// +// Created by Krunoslav Zaher on 10/18/15. +// Copyright © 2015 Krunoslav Zaher. All rights reserved. +// +import RxSwift +import RxCocoa + +private struct ActivityToken: ObservableConvertibleType, Disposable { + private let _source: Observable + private let _dispose: Cancelable + + init(source: Observable, disposeAction: @escaping () -> Void) { + _source = source + _dispose = Disposables.create(with: disposeAction) + } + + func dispose() { + _dispose.dispose() + } + + func asObservable() -> Observable { + _source + } +} + +/** +Enables monitoring of sequence computation. +If there is at least one sequence computation in progress, `true` will be sent. +When all activities complete `false` will be sent. +*/ +class ActivityIndicator: SharedSequenceConvertibleType { + typealias Element = Bool + typealias SharingStrategy = DriverSharingStrategy + + private let _lock = NSRecursiveLock() + private let _relay = BehaviorRelay(value: 0) + private let _loading: SharedSequence + + init() { + _loading = _relay.asDriver() + .map { $0 > 0 } + .distinctUntilChanged() + } + + fileprivate func trackActivityOfObservable( + _ source: Source + ) -> Observable { + return Observable.using({ () -> ActivityToken in + self.increment() + return ActivityToken(source: source.asObservable(), disposeAction: self.decrement) + }, observableFactory: { t in + return t.asObservable() + }) + } + + private func increment() { + _lock.lock() + _relay.accept(_relay.value + 1) + _lock.unlock() + } + + private func decrement() { + _lock.lock() + _relay.accept(_relay.value - 1) + _lock.unlock() + } + + func asSharedSequence() -> SharedSequence { + _loading + } +} + +extension ObservableConvertibleType { + func trackActivity(_ activityIndicator: ActivityIndicator) -> Observable { + activityIndicator.trackActivityOfObservable(self) + } +} diff --git a/Chuck Norris Facts/Core/Library/BaseCoordinator.swift b/Chuck Norris Facts/Core/Library/BaseCoordinator.swift new file mode 100644 index 0000000..4384042 --- /dev/null +++ b/Chuck Norris Facts/Core/Library/BaseCoordinator.swift @@ -0,0 +1,40 @@ +// +// BaseCoordinator.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import RxSwift +import Foundation + +class BaseCoordinator { + + typealias CoordinationResult = ResultType + + let disposeBag = DisposeBag() + + private let identifier = UUID() + + private var childCoordinators = [UUID: Any]() + + private func store(coordinator: BaseCoordinator) { + childCoordinators[coordinator.identifier] = coordinator + } + + private func free(coordinator: BaseCoordinator) { + childCoordinators[coordinator.identifier] = nil + } + + func coordinate(to coordinator: BaseCoordinator) -> Observable { + store(coordinator: coordinator) + return coordinator.start() + .do(onNext: { [weak self] _ in self?.free(coordinator: coordinator) }) + } + + func start() -> Observable { + fatalError("Start method should be implemented.") + } + +} diff --git a/Chuck Norris Facts/Core/Library/DynamicHeightCollectionView.swift b/Chuck Norris Facts/Core/Library/DynamicHeightCollectionView.swift new file mode 100644 index 0000000..48a3371 --- /dev/null +++ b/Chuck Norris Facts/Core/Library/DynamicHeightCollectionView.swift @@ -0,0 +1,23 @@ +// +// DynamicHeightCollectionView.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/26/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import UIKit + +final class DynamicHeightCollectionView: UICollectionView { + + override func layoutSubviews() { + super.layoutSubviews() + if bounds.size != intrinsicContentSize { + self.invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: CGSize { + return collectionViewLayout.collectionViewContentSize + } +} diff --git a/Chuck Norris Facts/Core/Library/JSON.swift b/Chuck Norris Facts/Core/Library/JSON.swift new file mode 100644 index 0000000..0c0783f --- /dev/null +++ b/Chuck Norris Facts/Core/Library/JSON.swift @@ -0,0 +1,21 @@ +// +// JSON.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/10/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +struct JSON { + static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } + + static var encoder: JSONEncoder { + JSONEncoder() + } +} diff --git a/Chuck Norris Facts/Core/Library/LaunchArgument.swift b/Chuck Norris Facts/Core/Library/LaunchArgument.swift new file mode 100644 index 0000000..2582e4f --- /dev/null +++ b/Chuck Norris Facts/Core/Library/LaunchArgument.swift @@ -0,0 +1,29 @@ +// +// LaunchArgument.swift +// Chuck Norris Facts +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/29/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +enum LaunchArgument: String { + // UI Testing + case uiTest = "--ui-test" + + // Reset storage + case resetData = "--reset-data" + + // Mock storage data + case mockStorage = "--mock-storage" + + // Mock Http Result + case mockHttp = "--mock-http" + + // Mock Http Error Result + case mockHttpError = "--mock-http-error" + + // Check if there is an argument on CommandLine arguments + static func check(_ argument: LaunchArgument) -> Bool { + CommandLine.arguments.contains(argument.rawValue) + } +} diff --git a/Chuck Norris Facts/Resources/Animations/empty-box.json b/Chuck Norris Facts/Resources/Animations/empty-box.json new file mode 100644 index 0000000..16e6952 --- /dev/null +++ b/Chuck Norris Facts/Resources/Animations/empty-box.json @@ -0,0 +1 @@ +{"v":"4.7.0","fr":25,"ip":0,"op":50,"w":120,"h":120,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"ruoi","ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.967]},"o":{"x":[0.167],"y":[0.033]},"n":["0p833_0p967_0p167_0p033"],"t":35,"s":[100],"e":[0]},{"t":49}]},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0,"y":0},"n":"0p833_0p833_0_0","t":0,"s":[57.361,61.016,0],"e":[57.699,41.796,0],"to":[-4.67500305175781,-4.12800598144531,0],"ti":[-13.9099960327148,5.27300262451172,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":10.219,"s":[57.699,41.796,0],"e":[79.084,33.982,0],"to":[12.8159942626953,-4.85800170898438,0],"ti":[-4.54498291015625,3.73400115966797,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":19.445,"s":[79.084,33.982,0],"e":[59.691,9.121,0],"to":[6.61601257324219,-5.43799591064453,0],"ti":[20.0290069580078,1.20700073242188,0]},{"t":35}]},"a":{"a":0,"k":[60.531,10.945,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.994,0],[0,-0.994],[0.995,0],[0,0.994]],"o":[[0.995,0],[0,0.994],[-0.994,0],[0,-0.994]],"v":[[-0.001,-1.801],[1.801,-0.001],[-0.001,1.801],[-1.801,-0.001]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.529,0.529,0.529,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[62.4,13.144],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.422,0],[0,-1.422],[1.421,0],[0,1.422]],"o":[[1.421,0],[0,1.422],[-1.422,0],[0,-1.422]],"v":[[0.001,-2.574],[2.574,0],[0.001,2.574],[-2.574,0]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"st","c":{"a":0,"k":[0.529,0.529,0.529,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0.7},"lc":1,"lj":1,"ml":10,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[64.145,9.606],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":3,"cix":2,"ix":2,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.996,0],[0,-1.996],[1.996,0],[0,1.996]],"o":[[1.996,0],[0,1.996],[-1.996,0],[0,-1.996]],"v":[[0,-3.614],[3.614,0],[0,3.614],[-3.614,0]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"st","c":{"a":0,"k":[0.529,0.529,0.529,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0.7},"lc":1,"lj":1,"ml":10,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[57.957,10.552],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":3,"cix":2,"ix":3,"mn":"ADBE Vector Group"},{"ty":"tr","p":{"a":0,"k":[60.531,10.941],"ix":2},"a":{"a":0,"k":[60.531,10.941],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"ruoi","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":50,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 2","ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.967]},"o":{"x":[0.167],"y":[0.033]},"n":["0p833_0p967_0p167_0p033"],"t":35,"s":[100],"e":[0]},{"t":49}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[-0.75,-0.75,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[-13.91,5.273],[-4.545,3.734],[20.029,1.207]],"o":[[-4.675,-4.128],[12.816,-4.858],[6.616,-5.438],[0,0]],"v":[[-7.383,24.76],[-7.046,5.54],[14.34,-2.273],[-3.178,-24.76]],"c":false}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"st","c":{"a":0,"k":[0.627,0.627,0.627,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1},"lc":2,"lj":2,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":2.028}},{"n":"g","nm":"gap","v":{"a":0,"k":2.028}},{"n":"o","nm":"offset","v":{"a":0,"k":0}}],"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"tr","p":{"a":0,"k":[67.87,37.631],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.953]},"o":{"x":[0.167],"y":[0.033]},"n":["0p833_0p953_0p167_0p033"],"t":0,"s":[0],"e":[100]},{"t":35}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim"}],"ip":0,"op":50,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":3,"ty":4,"nm":"im_emptyBox Outlines","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[60,60,0]},"a":{"a":0,"k":[60,60,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-0.001,-16.607],[-32.143,-0.002],[-0.001,16.607],[32.144,-0.002]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.8,0.82,0.851,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[60,55.75],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[12.856,-23.249],[0,-16.605],[-12.857,-23.249],[-45,-6.641],[-32.144,0.001],[-45,6.645],[-12.857,23.249],[0,16.609],[12.856,23.249],[45,6.645],[32.143,0.001],[45,-6.641]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.957,0.957,0.957,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[60,55.748],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 8","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-16.072,24.171],[16.072,11.312],[16.072,-24.171],[-16.072,-24.171]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.902,0.914,0.929,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[76.072,83.33],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 9","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-32.143,-24.171],[-32.143,11.311],[-0.001,24.171],[32.144,11.311],[32.144,-24.171]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.8,0.82,0.851,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[60,83.33],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":2,"cix":2,"ix":4,"mn":"ADBE Vector Group"},{"ty":"tr","p":{"a":0,"k":[60,60.186],"ix":2},"a":{"a":0,"k":[60,60.186],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"box","np":4,"cix":2,"ix":1,"mn":"ADBE Vector Group"}],"ip":0,"op":50,"st":0,"bm":0,"sr":1}]} diff --git a/Chuck Norris Facts/Resources/Animations/loading.json b/Chuck Norris Facts/Resources/Animations/loading.json new file mode 100644 index 0000000..aa67787 --- /dev/null +++ b/Chuck Norris Facts/Resources/Animations/loading.json @@ -0,0 +1 @@ +{"v":"5.5.2","fr":30,"ip":0,"op":30,"w":500,"h":500,"nm":"合成 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"形状图层 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[407,215,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":0,"s":[48,48]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":20,"s":[48,48]},{"t":30,"s":[72,72]}],"ix":2,"x":"var $bm_rt;\nvar amp, freq, decay, n, n, t, t, v;\namp = 0.1;\nfreq = 2;\ndecay = 2;\n$bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = t = 0;\n} else {\n $bm_rt = t = $bm_sub(time, key(n).time);\n}\nif (n > 0) {\n v = velocityAtTime($bm_sub(key(n).time, $bm_div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_sum(value, $bm_div($bm_mul($bm_mul(v, amp), Math.sin($bm_mul($bm_mul($bm_mul(freq, t), 2), Math.PI))), Math.exp($bm_mul(decay, t))));\n} else {\n $bm_rt = value;\n}"},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0.768627464771,0.784313738346,0.800000011921,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[0.768627464771,0.784313738346,0.800000011921,1]},{"t":30,"s":[0.388235300779,0.403921574354,0.419607847929,1]}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-28,36],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":150,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"形状图层 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[279,215,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":0,"s":[48,48]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":10,"s":[48,48]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":20,"s":[72,72]},{"t":30,"s":[48,48]}],"ix":2,"x":"var $bm_rt;\nvar amp, freq, decay, n, n, t, t, v;\namp = 0.1;\nfreq = 2;\ndecay = 2;\n$bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = t = 0;\n} else {\n $bm_rt = t = $bm_sub(time, key(n).time);\n}\nif (n > 0) {\n v = velocityAtTime($bm_sub(key(n).time, $bm_div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_sum(value, $bm_div($bm_mul($bm_mul(v, amp), Math.sin($bm_mul($bm_mul($bm_mul(freq, t), 2), Math.PI))), Math.exp($bm_mul(decay, t))));\n} else {\n $bm_rt = value;\n}"},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0.768627464771,0.784313738346,0.800000011921,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":20,"s":[0.388235300779,0.403921574354,0.419607847929,1]},{"t":30,"s":[0.768627464771,0.784313738346,0.800000011921,1]}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-28,36],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":150,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"形状图层 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[151,215,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":0,"s":[48,48]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":10,"s":[72,72]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":20,"s":[48,48]},{"t":30,"s":[48,48]}],"ix":2,"x":"var $bm_rt;\nvar amp, freq, decay, n, n, t, t, v;\namp = 0.1;\nfreq = 2;\ndecay = 2;\n$bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = t = 0;\n} else {\n $bm_rt = t = $bm_sub(time, key(n).time);\n}\nif (n > 0) {\n v = velocityAtTime($bm_sub(key(n).time, $bm_div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_sum(value, $bm_div($bm_mul($bm_mul(v, amp), Math.sin($bm_mul($bm_mul($bm_mul(freq, t), 2), Math.PI))), Math.exp($bm_mul(decay, t))));\n} else {\n $bm_rt = value;\n}"},"p":{"a":0,"k":[0,0],"ix":3},"nm":"椭圆路径 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0.768627464771,0.784313738346,0.800000011921,1]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":10,"s":[0.388235300779,0.403921574354,0.419607847929,1]},{"t":20,"s":[0.768627464771,0.784313738346,0.800000011921,1]}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"填充 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-28,36],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"变换"}],"nm":"椭圆 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":150,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/Chuck Norris Facts/Assets.xcassets/AppIcon.appiconset/Contents.json b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 63% rename from Chuck Norris Facts/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index 9221b9b..1e2d708 100644 --- a/Chuck Norris Facts/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,91 +1,115 @@ { "images" : [ { + "filename" : "Icon-App-20x20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { + "filename" : "Icon-App-20x20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { + "filename" : "Icon-App-29x29@1x.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-App-29x29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { + "filename" : "Icon-App-29x29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { + "filename" : "Icon-App-40x40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { + "filename" : "Icon-App-40x40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { + "filename" : "Icon-App-60x60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { + "filename" : "Icon-App-60x60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { + "filename" : "Icon-App-20x20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { + "filename" : "Icon-App-20x20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { + "filename" : "Icon-App-29x29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { + "filename" : "Icon-App-29x29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { + "filename" : "Icon-App-40x40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { + "filename" : "Icon-App-40x40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { + "filename" : "Icon-App-76x76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { + "filename" : "Icon-App-76x76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { + "filename" : "Icon-App-83.5x83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { + "filename" : "ItunesArtwork@2x.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..6cd3392 Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..1da8f00 Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..46ee5b3 Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..d3a6923 Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..6d27928 Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..1f966ce Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..1da8f00 Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..a28fdd5 Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..2273b5a Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..2273b5a Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..1897cde Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..41f709c Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..00e38ee Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..8830d6e Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png new file mode 100644 index 0000000..045af95 Binary files /dev/null and b/Chuck Norris Facts/Resources/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png differ diff --git a/Chuck Norris Facts/Assets.xcassets/Contents.json b/Chuck Norris Facts/Resources/Assets.xcassets/Contents.json similarity index 100% rename from Chuck Norris Facts/Assets.xcassets/Contents.json rename to Chuck Norris Facts/Resources/Assets.xcassets/Contents.json diff --git a/Chuck Norris Facts/Base.lproj/LaunchScreen.storyboard b/Chuck Norris Facts/Resources/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from Chuck Norris Facts/Base.lproj/LaunchScreen.storyboard rename to Chuck Norris Facts/Resources/Base.lproj/LaunchScreen.storyboard diff --git a/Chuck Norris Facts/Resources/Generated/Strings.swift b/Chuck Norris Facts/Resources/Generated/Strings.swift new file mode 100644 index 0000000..c898466 --- /dev/null +++ b/Chuck Norris Facts/Resources/Generated/Strings.swift @@ -0,0 +1,80 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +import Foundation + +// swiftlint:disable superfluous_disable_command file_length implicit_return + +// MARK: - Strings + +// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces +internal enum L10n { + + internal enum Common { + /// Ok + internal static let ok = L10n.tr("Localizable", "Common.ok") + /// Oops + internal static let oops = L10n.tr("Localizable", "Common.oops") + } + + internal enum EmptyView { + /// Looks like there are no Facts + internal static let empty = L10n.tr("Localizable", "EmptyView.empty") + /// There are no facts to your search + internal static let emptySearch = L10n.tr("Localizable", "EmptyView.emptySearch") + /// Search + internal static let search = L10n.tr("Localizable", "EmptyView.search") + } + + internal enum Errors { + /// Can't search facts + internal static let cantSearchFacts = L10n.tr("Localizable", "Errors.cantSearchFacts") + /// Can't sync categories + internal static let cantSyncCategories = L10n.tr("Localizable", "Errors.cantSyncCategories") + } + + internal enum FactCategory { + /// UNCATEGORIZED + internal static let uncategorized = L10n.tr("Localizable", "FactCategory.uncategorized") + } + + internal enum FactsList { + /// Chuck Norris Facts + internal static let title = L10n.tr("Localizable", "FactsList.title") + } + + internal enum SearchFacts { + /// Search + internal static let title = L10n.tr("Localizable", "SearchFacts.title") + internal enum Sections { + /// Past Searches + internal static let pastSearches = L10n.tr("Localizable", "SearchFacts.sections.pastSearches") + /// Suggestions + internal static let suggestions = L10n.tr("Localizable", "SearchFacts.sections.suggestions") + } + } +} +// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces + +// MARK: - Implementation Details + +extension L10n { + private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { + let format = BundleToken.bundle.localizedString(forKey: key, value: nil, table: table) + return String(format: format, locale: Locale.current, arguments: args) + } +} + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type diff --git a/Chuck Norris Facts/Info.plist b/Chuck Norris Facts/Resources/Info.plist similarity index 93% rename from Chuck Norris Facts/Info.plist rename to Chuck Norris Facts/Resources/Info.plist index 2a3483c..9742bf0 100644 --- a/Chuck Norris Facts/Info.plist +++ b/Chuck Norris Facts/Resources/Info.plist @@ -33,16 +33,12 @@ Default Configuration UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main UILaunchStoryboardName LaunchScreen - UIMainStoryboardFile - Main UIRequiredDeviceCapabilities armv7 diff --git a/Chuck Norris Facts/Resources/Localizable.strings b/Chuck Norris Facts/Resources/Localizable.strings new file mode 100644 index 0000000..96fbb5c --- /dev/null +++ b/Chuck Norris Facts/Resources/Localizable.strings @@ -0,0 +1,25 @@ +/* + Localizable.strings + Chuck Norris Facts + + Created by Djorkaeff Alexandre Vilela Pereira on 10/29/20. + Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +*/ + +"FactsList.title" = "Chuck Norris Facts"; + +"SearchFacts.title" = "Search"; +"SearchFacts.sections.suggestions" = "Suggestions"; +"SearchFacts.sections.pastSearches" = "Past Searches"; + +"EmptyView.search" = "Search"; +"EmptyView.empty" = "Looks like there are no Facts"; +"EmptyView.emptySearch" = "There are no facts to your search"; + +"Common.oops" = "Oops"; +"Common.ok" = "Ok"; + +"Errors.cantSyncCategories" = "Can't sync categories"; +"Errors.cantSearchFacts" = "Can't search facts"; + +"FactCategory.uncategorized" = "UNCATEGORIZED"; diff --git a/Chuck Norris Facts/Resources/Stubs/facts.json b/Chuck Norris Facts/Resources/Stubs/facts.json new file mode 100644 index 0000000..1b7c6d5 --- /dev/null +++ b/Chuck Norris Facts/Resources/Stubs/facts.json @@ -0,0 +1,150 @@ +[ + { + "categories": [ + "movie" + ], + "created_at": "2020-01-05 13:42:19.576875", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "sudkgw_tr_ejehjag7cqwq", + "updated_at": "2020-01-05 13:42:19.576875", + "url": "https:\/\/api.chucknorris.io\/jokes\/sudkgw_tr_ejehjag7cqwq", + "value": "The opening scene of the movie \"Saving Private Ryan\" is loosely based on games of dodgeball Chuck Norris played in second grade." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:19.576875", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "H7lHICEVSsW25ffciJEjxw", + "updated_at": "2020-01-05 13:42:19.576875", + "url": "https:\/\/api.chucknorris.io\/jokes\/H7lHICEVSsW25ffciJEjxw", + "value": "Chuck Norris can play Xbox Kinect games on his PlayStation4 and PlayStation Move games on his Xbox 720." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:20.568859", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "0fvCgPtrRqe3BzC8jxEkUA", + "updated_at": "2020-01-05 13:42:20.568859", + "url": "https:\/\/api.chucknorris.io\/jokes\/0fvCgPtrRqe3BzC8jxEkUA", + "value": "Chuck Norris doesn't need to play games against people to beat their high scores. He just plays with himself and beats every highscore on every game on every console in the whole entire universe." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:21.795084", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "2COz4ZY4SJaM7WKJUmSZ3Q", + "updated_at": "2020-01-05 13:42:21.795084", + "url": "https:\/\/api.chucknorris.io\/jokes\/2COz4ZY4SJaM7WKJUmSZ3Q", + "value": "Michael Phelps currently holds the record for most Olympic gold medals in a single Games with 8. That record will be broken in 2012, when Chuck Norris wins 22." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:23.484083", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "eOcHK252SCmv6T5MsJiexA", + "updated_at": "2020-01-05 13:42:23.484083", + "url": "https:\/\/api.chucknorris.io\/jokes\/eOcHK252SCmv6T5MsJiexA", + "value": "Why did Chuck Norris hasn't appeared on any mortal kombat games. Simple, the name says it all. \"mortal\". Also there won't be any fatality tha will work on him, he will just roundhouse kick anyone either he wins or loose." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:23.484083", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "BUBK6qDSRqWevu0YGEEZvw", + "updated_at": "2020-01-05 13:42:23.484083", + "url": "https:\/\/api.chucknorris.io\/jokes\/BUBK6qDSRqWevu0YGEEZvw", + "value": "Chuck Norris can fight better than all fighting video games. How? He instantly wins." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.099703", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "pYt9of-uQPqyPhk85Z-zUA", + "updated_at": "2020-01-05 13:42:25.099703", + "url": "https:\/\/api.chucknorris.io\/jokes\/pYt9of-uQPqyPhk85Z-zUA", + "value": "If Chuck Norris were a PC or Mac he'd be a Mac because you can't play games with Chuck Norris" + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.628594", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "x6hL23bhTEK03DUlagsIUQ", + "updated_at": "2020-01-05 13:42:25.628594", + "url": "https:\/\/api.chucknorris.io\/jokes\/x6hL23bhTEK03DUlagsIUQ", + "value": "Chuck Norris enjoys playing backyard games with his grandchildren. They often play badminton. But instead of using little sissy racquets & a plastic birdie, they use boat oars & dead chickens." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.905626", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "-j_jS99eTIi7hrDpRQ9qLw", + "updated_at": "2020-01-05 13:42:25.905626", + "url": "https:\/\/api.chucknorris.io\/jokes\/-j_jS99eTIi7hrDpRQ9qLw", + "value": "Chuck Norris is forbidden from competing in paintball games... for very fucking obvious reasons." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.194739", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "20QgKwidT1-ySGoHJCpwSw", + "updated_at": "2020-01-05 13:42:26.194739", + "url": "https:\/\/api.chucknorris.io\/jokes\/20QgKwidT1-ySGoHJCpwSw", + "value": "It's all fun and games until Chuck Norris pulls your eyes out with a socket wrench." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.766831", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "RB2hbqTzTd2ORXy53ITqqQ", + "updated_at": "2020-01-05 13:42:26.766831", + "url": "https:\/\/api.chucknorris.io\/jokes\/RB2hbqTzTd2ORXy53ITqqQ", + "value": "Chuck Norris finished every Call of Duty games in less than 15 minutes..........without shooting a single bullet." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.766831", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "1Iy7_hYKT5GgOfxkYuTK3A", + "updated_at": "2020-01-05 13:42:26.766831", + "url": "https:\/\/api.chucknorris.io\/jokes\/1Iy7_hYKT5GgOfxkYuTK3A", + "value": "Chuck Norris is unstoppable in all games of Call of Duty" + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:27.496799", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "4QsnKWP-QFar62XWvYTTsw", + "updated_at": "2020-01-05 13:42:27.496799", + "url": "https:\/\/api.chucknorris.io\/jokes\/4QsnKWP-QFar62XWvYTTsw", + "value": "Chuck Norris invented the olympic games. with his left pinky." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:28.664997", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "R3xVlG1FR7qySlLqsK5Yjw", + "updated_at": "2020-01-05 13:42:28.664997", + "url": "https:\/\/api.chucknorris.io\/jokes\/R3xVlG1FR7qySlLqsK5Yjw", + "value": "Chuck Norris' last birthday party was held at the La Brea Tar Pits where he enjoyed all of the party games and easily won the 'dunking for dinosaurs' event." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:29.296379", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "baXxcGBqQG6an7udXMTQWA", + "updated_at": "2020-01-05 13:42:29.296379", + "url": "https:\/\/api.chucknorris.io\/jokes\/baXxcGBqQG6an7udXMTQWA", + "value": "When Chuck Norris plays a game, every minute is potentially \"Sudden Death\" for his opponents...including cards and board games." + }, + { + "categories": [ + "celebrity" + ], + "created_at": "2020-01-05 13:42:29.855523", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "l7QlUREJQzOIJVB88DY9jg", + "updated_at": "2020-01-05 13:42:29.855523", + "url": "https:\/\/api.chucknorris.io\/jokes\/l7QlUREJQzOIJVB88DY9jg", + "value": "Chuck Norris was at the X-games getting ready for competition when he got a message from Paris Hilton saying that she had sent him a friend request on MySpace. An infuriated Chuck Norris logged on to MySpace using his skateboard and rejected the request immediately." + } +] diff --git a/Chuck Norris Facts/Resources/Stubs/get-categories.json b/Chuck Norris Facts/Resources/Stubs/get-categories.json new file mode 100644 index 0000000..7fd08b8 --- /dev/null +++ b/Chuck Norris Facts/Resources/Stubs/get-categories.json @@ -0,0 +1 @@ +["animal","career","celebrity","dev","explicit","fashion","food","history","money","movie","music","political","religion","science","sport","travel"] diff --git a/Chuck Norris Facts/Resources/Stubs/search-facts.json b/Chuck Norris Facts/Resources/Stubs/search-facts.json new file mode 100644 index 0000000..3cb93ab --- /dev/null +++ b/Chuck Norris Facts/Resources/Stubs/search-facts.json @@ -0,0 +1,153 @@ +{ + "total": 16, + "result": [ + { + "categories": [ + "movie" + ], + "created_at": "2020-01-05 13:42:19.576875", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "sudkgw_tr_ejehjag7cqwq", + "updated_at": "2020-01-05 13:42:19.576875", + "url": "https:\/\/api.chucknorris.io\/jokes\/sudkgw_tr_ejehjag7cqwq", + "value": "The opening scene of the movie \"Saving Private Ryan\" is loosely based on games of dodgeball Chuck Norris played in second grade." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:19.576875", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "H7lHICEVSsW25ffciJEjxw", + "updated_at": "2020-01-05 13:42:19.576875", + "url": "https:\/\/api.chucknorris.io\/jokes\/H7lHICEVSsW25ffciJEjxw", + "value": "Chuck Norris can play Xbox Kinect games on his PlayStation4 and PlayStation Move games on his Xbox 720." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:20.568859", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "0fvCgPtrRqe3BzC8jxEkUA", + "updated_at": "2020-01-05 13:42:20.568859", + "url": "https:\/\/api.chucknorris.io\/jokes\/0fvCgPtrRqe3BzC8jxEkUA", + "value": "Chuck Norris doesn't need to play games against people to beat their high scores. He just plays with himself and beats every highscore on every game on every console in the whole entire universe." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:21.795084", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "2COz4ZY4SJaM7WKJUmSZ3Q", + "updated_at": "2020-01-05 13:42:21.795084", + "url": "https:\/\/api.chucknorris.io\/jokes\/2COz4ZY4SJaM7WKJUmSZ3Q", + "value": "Michael Phelps currently holds the record for most Olympic gold medals in a single Games with 8. That record will be broken in 2012, when Chuck Norris wins 22." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:23.484083", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "eOcHK252SCmv6T5MsJiexA", + "updated_at": "2020-01-05 13:42:23.484083", + "url": "https:\/\/api.chucknorris.io\/jokes\/eOcHK252SCmv6T5MsJiexA", + "value": "Why did Chuck Norris hasn't appeared on any mortal kombat games. Simple, the name says it all. \"mortal\". Also there won't be any fatality tha will work on him, he will just roundhouse kick anyone either he wins or loose." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:23.484083", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "BUBK6qDSRqWevu0YGEEZvw", + "updated_at": "2020-01-05 13:42:23.484083", + "url": "https:\/\/api.chucknorris.io\/jokes\/BUBK6qDSRqWevu0YGEEZvw", + "value": "Chuck Norris can fight better than all fighting video games. How? He instantly wins." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.099703", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "pYt9of-uQPqyPhk85Z-zUA", + "updated_at": "2020-01-05 13:42:25.099703", + "url": "https:\/\/api.chucknorris.io\/jokes\/pYt9of-uQPqyPhk85Z-zUA", + "value": "If Chuck Norris were a PC or Mac he'd be a Mac because you can't play games with Chuck Norris" + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.628594", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "x6hL23bhTEK03DUlagsIUQ", + "updated_at": "2020-01-05 13:42:25.628594", + "url": "https:\/\/api.chucknorris.io\/jokes\/x6hL23bhTEK03DUlagsIUQ", + "value": "Chuck Norris enjoys playing backyard games with his grandchildren. They often play badminton. But instead of using little sissy racquets & a plastic birdie, they use boat oars & dead chickens." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.905626", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "-j_jS99eTIi7hrDpRQ9qLw", + "updated_at": "2020-01-05 13:42:25.905626", + "url": "https:\/\/api.chucknorris.io\/jokes\/-j_jS99eTIi7hrDpRQ9qLw", + "value": "Chuck Norris is forbidden from competing in paintball games... for very fucking obvious reasons." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.194739", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "20QgKwidT1-ySGoHJCpwSw", + "updated_at": "2020-01-05 13:42:26.194739", + "url": "https:\/\/api.chucknorris.io\/jokes\/20QgKwidT1-ySGoHJCpwSw", + "value": "It's all fun and games until Chuck Norris pulls your eyes out with a socket wrench." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.766831", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "RB2hbqTzTd2ORXy53ITqqQ", + "updated_at": "2020-01-05 13:42:26.766831", + "url": "https:\/\/api.chucknorris.io\/jokes\/RB2hbqTzTd2ORXy53ITqqQ", + "value": "Chuck Norris finished every Call of Duty games in less than 15 minutes..........without shooting a single bullet." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.766831", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "1Iy7_hYKT5GgOfxkYuTK3A", + "updated_at": "2020-01-05 13:42:26.766831", + "url": "https:\/\/api.chucknorris.io\/jokes\/1Iy7_hYKT5GgOfxkYuTK3A", + "value": "Chuck Norris is unstoppable in all games of Call of Duty" + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:27.496799", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "4QsnKWP-QFar62XWvYTTsw", + "updated_at": "2020-01-05 13:42:27.496799", + "url": "https:\/\/api.chucknorris.io\/jokes\/4QsnKWP-QFar62XWvYTTsw", + "value": "Chuck Norris invented the olympic games. with his left pinky." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:28.664997", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "R3xVlG1FR7qySlLqsK5Yjw", + "updated_at": "2020-01-05 13:42:28.664997", + "url": "https:\/\/api.chucknorris.io\/jokes\/R3xVlG1FR7qySlLqsK5Yjw", + "value": "Chuck Norris' last birthday party was held at the La Brea Tar Pits where he enjoyed all of the party games and easily won the 'dunking for dinosaurs' event." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:29.296379", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "baXxcGBqQG6an7udXMTQWA", + "updated_at": "2020-01-05 13:42:29.296379", + "url": "https:\/\/api.chucknorris.io\/jokes\/baXxcGBqQG6an7udXMTQWA", + "value": "When Chuck Norris plays a game, every minute is potentially \"Sudden Death\" for his opponents...including cards and board games." + }, + { + "categories": [ + "celebrity" + ], + "created_at": "2020-01-05 13:42:29.855523", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "l7QlUREJQzOIJVB88DY9jg", + "updated_at": "2020-01-05 13:42:29.855523", + "url": "https:\/\/api.chucknorris.io\/jokes\/l7QlUREJQzOIJVB88DY9jg", + "value": "Chuck Norris was at the X-games getting ready for competition when he got a message from Paris Hilton saying that she had sent him a friend request on MySpace. An infuriated Chuck Norris logged on to MySpace using his skateboard and rejected the request immediately." + } + ] +} diff --git a/Chuck Norris Facts/SceneDelegate.swift b/Chuck Norris Facts/SceneDelegate.swift deleted file mode 100644 index 74a0e4e..0000000 --- a/Chuck Norris Facts/SceneDelegate.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// SceneDelegate.swift -// Chuck Norris Facts -// -// Created by Djorkaeff Alexandre Vilela Pereira on 10/9/20. -// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. -// - -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } - - -} - diff --git a/Chuck Norris Facts/ViewController.swift b/Chuck Norris Facts/ViewController.swift deleted file mode 100644 index 2a216ef..0000000 --- a/Chuck Norris Facts/ViewController.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// ViewController.swift -// Chuck Norris Facts -// -// Created by Djorkaeff Alexandre Vilela Pereira on 10/9/20. -// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. -// - -import UIKit - -class ViewController: UIViewController { - - override func viewDidLoad() { - super.viewDidLoad() - // Do any additional setup after loading the view. - } - - -} - diff --git a/Chuck Norris FactsTests/Chuck_Norris_FactsTests.swift b/Chuck Norris FactsTests/Chuck_Norris_FactsTests.swift deleted file mode 100644 index 025a84c..0000000 --- a/Chuck Norris FactsTests/Chuck_Norris_FactsTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Chuck_Norris_FactsTests.swift -// Chuck Norris FactsTests -// -// Created by Djorkaeff Alexandre Vilela Pereira on 10/9/20. -// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. -// - -import XCTest -@testable import Chuck_Norris_Facts - -class Chuck_Norris_FactsTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/Chuck Norris FactsTests/Data/Services/FactsServiceTests.swift b/Chuck Norris FactsTests/Data/Services/FactsServiceTests.swift new file mode 100644 index 0000000..339fa86 --- /dev/null +++ b/Chuck Norris FactsTests/Data/Services/FactsServiceTests.swift @@ -0,0 +1,120 @@ +// +// FactsServiceTests.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/21/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import XCTest +import RxSwift +import RxBlocking +import RxTest +import RealmSwift + +@testable import Chuck_Norris_Facts + +final class FactsServiceTests: XCTestCase { + + var factsService: FactsServiceType! + var factsStorage: FactsStorageType! + var factsProvider: APIMock! + var realm: Realm! + + var disposeBag: DisposeBag! + var testScheduler: TestScheduler! + + override func setUpWithError() throws { + testScheduler = TestScheduler(initialClock: 0) + disposeBag = DisposeBag() + realm = try Realm(configuration: .init(inMemoryIdentifier: self.name)) + factsStorage = FactsStorage(realm: realm) + factsProvider = APIMock() + factsService = FactsService(provider: factsProvider, storage: factsStorage, scheduler: MainScheduler.instance) + } + + override func tearDown() { + testScheduler = nil + disposeBag = nil + factsService = nil + factsStorage = nil + + try? realm.write { + realm.deleteAll() + } + } + + func test_FactsService_WhenSyncCategories_ShouldSaveCategoriesOnDatabase() throws { + factsProvider.mockRequest(statusCode: 200, data: stub("get-categories")) + + let storedCategories = factsStorage.retrieveCategories() + let categories = try storedCategories.toBlocking().first() ?? [] + XCTAssertTrue(categories.isEmpty) + + factsService.syncCategories() + .subscribe() + .disposed(by: disposeBag) + + let savedCategories = try storedCategories.toBlocking().first() + XCTAssertEqual(savedCategories?.count, 16) + } + + func test_FactsService_WhenSearchFacts_ShouldSaveSearchTermOnDatabase() throws { + factsProvider.mockRequest(statusCode: 200, data: stub("search-facts")) + + let storedSearches = factsStorage.retrieveSearches() + let searches = try storedSearches.toBlocking().first() ?? [] + XCTAssertTrue(searches.isEmpty) + + factsService.searchFacts(searchTerm: "games") + .subscribe() + .disposed(by: disposeBag) + + testScheduler.start() + + let savedSearches = try storedSearches.toBlocking().first() + XCTAssertEqual(savedSearches?.count, 1) + } + + func test_FactsService_WhenRetrieveCategories_ShouldReturnCategoriesOnDatabase() throws { + let storedCategories = factsStorage.retrieveCategories() + let categories = try storedCategories.toBlocking().first() ?? [] + XCTAssertTrue(categories.isEmpty) + + let stubCategories = try stub("get-categories", type: [FactCategory].self) + let mockCategories = try XCTUnwrap(stubCategories) + factsStorage.storeCategories(mockCategories) + + let savedCategories = try storedCategories.toBlocking().first() + XCTAssertEqual(savedCategories?.count, mockCategories.count) + } + + func test_FactsService_WhenSearchFacts_ShouldReturnFacts() throws { + factsProvider.mockRequest(statusCode: 200, data: stub("search-facts")) + + let factsObserver = testScheduler.createObserver([Fact].self) + + factsService.searchFacts(searchTerm: "games") + .subscribe(factsObserver) + .disposed(by: disposeBag) + + testScheduler.start() + + let facts = factsObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(facts?.count, 16) + } + + func test_FactsService_WhenRetrievePastSearches_ShouldReturnDistinctSortedByDateSearches() throws { + let storedSearches = factsStorage.retrieveSearches() + let searches = try storedSearches.toBlocking().first() ?? [] + XCTAssertTrue(searches.isEmpty) + + factsStorage.storeSearch(searchTerm: "games") + factsStorage.storeSearch(searchTerm: "explicit") + factsStorage.storeSearch(searchTerm: "explicit") + factsStorage.storeSearch(searchTerm: "fashion") + + let savedSearches = try storedSearches.toBlocking().first() + XCTAssertEqual(savedSearches, ["fashion", "explicit", "games"]) + } +} diff --git a/Chuck Norris FactsTests/Library/XCTestCase+Stub.swift b/Chuck Norris FactsTests/Library/XCTestCase+Stub.swift new file mode 100644 index 0000000..5736e7b --- /dev/null +++ b/Chuck Norris FactsTests/Library/XCTestCase+Stub.swift @@ -0,0 +1,42 @@ +// +// Fact+Extensions.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/12/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import XCTest + +@testable import Chuck_Norris_Facts + +extension XCTestCase { + + func stub(_ resource: String, type: T.Type, decoder: JSONDecoder = JSON.decoder) throws -> T? { + let bundle = Bundle(for: Self.self) + + guard let url = bundle.url(forResource: resource, withExtension: ".json") else { + return nil + } + + let data = try Data(contentsOf: url) + let stub = try decoder.decode(type, from: data) + return stub + } + + func stub(_ resource: String) -> Data? { + let bundle = Bundle(for: Self.self) + + do { + guard let url = bundle.url(forResource: resource, withExtension: ".json") else { + return nil + } + + let data = try Data(contentsOf: url) + return data + } catch { + return nil + } + } +} diff --git a/Chuck Norris FactsTests/Mocks/APIMock.swift b/Chuck Norris FactsTests/Mocks/APIMock.swift new file mode 100644 index 0000000..91a9340 --- /dev/null +++ b/Chuck Norris FactsTests/Mocks/APIMock.swift @@ -0,0 +1,28 @@ +// +// APIMock.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/30/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation + +@testable import Chuck_Norris_Facts + +final class APIMock: APIProvider { + + var requestReturnValue: Result? = .success(APIResponse(statusCode: 200, data: Data())) + + override func request( + _ target: FactsAPI, + completion: @escaping APIProvider.Completion + ) -> URLSessionDataTask? { + completion(requestReturnValue ?? .failure(.unknown(nil))) + return nil + } + + func mockRequest(statusCode: Int, data: Data?) { + requestReturnValue = .success(APIResponse(statusCode: statusCode, data: data)) + } +} diff --git a/Chuck Norris FactsTests/Mocks/FactsServiceMock.swift b/Chuck Norris FactsTests/Mocks/FactsServiceMock.swift new file mode 100644 index 0000000..511daed --- /dev/null +++ b/Chuck Norris FactsTests/Mocks/FactsServiceMock.swift @@ -0,0 +1,35 @@ +// +// FactsServiceMock.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/12/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import RxSwift + +@testable import Chuck_Norris_Facts + +final class FactsServiceMock: FactsServiceType { + + var syncCategoriesReturnValue: Observable = .just(()) + func syncCategories() -> Observable { + return syncCategoriesReturnValue + } + + var retrieveCategoriesReturnValue: Observable<[FactCategory]> = .just([]) + func retrieveCategories() -> Observable<[FactCategory]> { + return retrieveCategoriesReturnValue + } + + var searchFactsReturnValue: Observable<[Fact]> = .just([]) + func searchFacts(searchTerm: String) -> Observable<[Fact]> { + return searchFactsReturnValue + } + + var retrievePastSearchesReturnValue: Observable<[String]> = .just([]) + func retrievePastSearches() -> Observable<[String]> { + return retrievePastSearchesReturnValue + } +} diff --git a/Chuck Norris FactsTests/Scenes/Facts/FactsList/Fact/FactViewModelTests.swift b/Chuck Norris FactsTests/Scenes/Facts/FactsList/Fact/FactViewModelTests.swift new file mode 100644 index 0000000..1875096 --- /dev/null +++ b/Chuck Norris FactsTests/Scenes/Facts/FactsList/Fact/FactViewModelTests.swift @@ -0,0 +1,42 @@ +// +// FactViewModel.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/11/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import XCTest +import RxSwift +import RxBlocking +import RxTest + +@testable import Chuck_Norris_Facts + +class FactViewModelTests: XCTestCase { + + func test_FactViewModel_WhenWithoutCategory_ShouldHaveCategoryUncategorized() throws { + let factStub = try stub("short-fact", type: Fact.self) + let fact = try XCTUnwrap(factStub) + + let factViewModel = FactViewModel(fact: fact) + XCTAssertEqual(factViewModel.category, L10n.FactCategory.uncategorized) + } + + func test_FactViewModel_WhenCompare_ShouldBeEquatable() throws { + let factStub = try stub("short-fact", type: Fact.self) + let fact = try XCTUnwrap(factStub) + + let factViewModelTest = FactViewModel(fact: fact) + let factViewModel = FactViewModel(fact: fact) + XCTAssertEqual(factViewModelTest, factViewModel) + } + + func test_FactViewModel_WhenCompare_ShouldBeIdentifiable() throws { + let factStub = try stub("short-fact", type: Fact.self) + let fact = try XCTUnwrap(factStub) + + let factViewModelTest = FactViewModel(fact: fact) + XCTAssertEqual(factViewModelTest.identity, fact.id) + } +} diff --git a/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewControllerTests.swift b/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewControllerTests.swift new file mode 100644 index 0000000..0a91b42 --- /dev/null +++ b/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewControllerTests.swift @@ -0,0 +1,118 @@ +// +// FactsListViewControllerTests.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/11/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import XCTest +import RxSwift +import RxBlocking +import RxTest + +@testable import Chuck_Norris_Facts + +class FactsListViewControllerTests: XCTestCase { + + var factsListViewController: FactsListViewController! + var factsListViewModel: FactsListViewModel! + var factsServiceMock: FactsServiceMock! + var disposeBag: DisposeBag! + + override func setUp() { + disposeBag = DisposeBag() + factsServiceMock = FactsServiceMock() + factsListViewModel = FactsListViewModel(factsService: factsServiceMock) + factsListViewController = FactsListViewController() + factsListViewController.viewModel = factsListViewModel + + factsListViewController.loadViewIfNeeded() + } + + override func tearDown() { + disposeBag = nil + factsListViewModel = nil + factsListViewController = nil + } + + func test_FactsListViewController_WhenFactsIsEmpty_WhenSearchTermIsEmpty_ShouldShowEmptyList() { + factsServiceMock.searchFactsReturnValue = .just([]) + + factsListViewModel.inputs.viewDidAppear.onNext(()) + + XCTAssertFalse(factsListViewController.emptyListView.isHidden) + XCTAssertEqual(factsListViewController.emptyListView.label.text, L10n.EmptyView.empty) + XCTAssertFalse(factsListViewController.emptyListView.searchButton.isHidden) + } + + func test_FactsListViewController_WhenFactsIsEmpty_WhenSearchTermIsNotEmpty_ShouldShowEmptyList() { + factsServiceMock.searchFactsReturnValue = .just([]) + + factsListViewModel.inputs.setSearchTerm.onNext("games") + factsListViewModel.inputs.viewDidAppear.onNext(()) + + XCTAssertFalse(factsListViewController.emptyListView.isHidden) + XCTAssertEqual(factsListViewController.emptyListView.label.text, L10n.EmptyView.emptySearch) + XCTAssertTrue(factsListViewController.emptyListView.searchButton.isHidden) + } + + func test_FactCell_WhenContentIsShort_FontSizeShouldBeTitle1() throws { + let factStub = try stub("short-fact", type: Fact.self) + let fact = try XCTUnwrap(factStub) + + factsServiceMock.searchFactsReturnValue = .just([fact]) + + factsListViewModel.inputs.setSearchTerm.onNext("") + + let factCell = factsListFirstCell() + + XCTAssertEqual(factCell?.bodyLabel.font, .preferredFont(forTextStyle: .title1)) + } + + func test_FactCell_WhenContentIsLong_FontSizeShouldBeTitle3() throws { + let factStub = try stub("long-fact", type: Fact.self) + let fact = try XCTUnwrap(factStub) + + factsServiceMock.searchFactsReturnValue = .just([fact]) + + factsListViewModel.inputs.setSearchTerm.onNext("") + + let factCell = factsListFirstCell() + + XCTAssertEqual(factCell?.bodyLabel.font, .preferredFont(forTextStyle: .title3)) + } + + func test_FactCell_WhenTapShareFact_ShouldShowShareActivity() throws { + let factStub = try stub("short-fact", type: Fact.self) + let fact = try XCTUnwrap(factStub) + + factsServiceMock.searchFactsReturnValue = .just([fact]) + + let testScheduler = TestScheduler(initialClock: 0) + let shareFactObserver = testScheduler.createObserver(FactViewModel.self) + + factsListViewModel.inputs.setSearchTerm.onNext("games") + factsListViewModel.inputs.viewDidAppear.onNext(()) + + factsListViewModel.outputs.showShareFact + .subscribe(shareFactObserver) + .disposed(by: disposeBag) + + testScheduler.start() + + let factCell = factsListFirstCell() + factCell?.shareButton.sendActions(for: .touchUpInside) + + let events = shareFactObserver.events.compactMap { $0.value.element } + XCTAssertEqual(events.count, 1) + } + +} + +extension FactsListViewControllerTests { + func factsListFirstCell() -> FactCell? { + let indexPath = IndexPath(row: 0, section: 0) + return factsListViewController.tableView.cellForRow(at: indexPath) as? FactCell + } +} diff --git a/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewModelTests.swift b/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewModelTests.swift new file mode 100644 index 0000000..5187d85 --- /dev/null +++ b/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewModelTests.swift @@ -0,0 +1,127 @@ +// +// FactsListViewModelTests.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/11/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import XCTest +import RxSwift +import RxBlocking +import RxTest + +@testable import Chuck_Norris_Facts + +class FactsListViewModelTests: XCTestCase { + var factsListViewModel: FactsListViewModel! + var factsServiceMock: FactsServiceMock! + var testScheduler: TestScheduler! + + var disposeBag: DisposeBag! + + override func setUp() { + disposeBag = DisposeBag() + testScheduler = TestScheduler(initialClock: 0) + factsServiceMock = FactsServiceMock() + factsListViewModel = FactsListViewModel(factsService: factsServiceMock) + } + + override func tearDown() { + disposeBag = nil + testScheduler = nil + factsServiceMock = nil + factsListViewModel = nil + } + + func test_FactsListViewModel_WhenViewDidAppear_ShouldLoadEmptyFacts() throws { + factsServiceMock.searchFactsReturnValue = .just([]) + + let factsObserver = testScheduler.createObserver([FactsSectionModel].self) + + factsListViewModel.outputs.facts + .subscribe(factsObserver) + .disposed(by: disposeBag) + + factsListViewModel.inputs.viewDidAppear.onNext(()) + + testScheduler.start() + + let sectionModels = factsObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(sectionModels?.first?.items.count, 0) + } + + func test_FactsListViewModel_WhenStartShareFact_ShouldShowShareFact() throws { + let factStub = try stub("short-fact", type: Fact.self) + let fact = try XCTUnwrap(factStub) + + factsListViewModel.inputs.viewDidAppear.onNext(()) + + let factObserver = testScheduler.createObserver(FactViewModel.self) + + factsListViewModel.outputs.showShareFact + .subscribe(factObserver) + .disposed(by: disposeBag) + + let factViewModel = FactViewModel(fact: fact) + factsListViewModel.inputs.startShareFact.onNext(factViewModel) + + let shareFact = factObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(fact.value, shareFact?.text) + } + + func test_FactsListViewModel_WhenViewDidAppear_ShouldSyncCategoriesWithNoErrors() throws { + let stubCategories = try stub("get-categories", type: [FactCategory].self) ?? [] + let categories = try XCTUnwrap(stubCategories) + factsServiceMock.retrieveCategoriesReturnValue = .just(categories) + + let errorObserver = testScheduler.createObserver(FactsListErrorViewModel.self) + + factsListViewModel.outputs.factsListError + .subscribe(errorObserver) + .disposed(by: disposeBag) + + factsListViewModel.inputs.viewDidAppear.onNext(()) + + testScheduler.start() + + let error = errorObserver.events.compactMap { $0.value.element }.first + XCTAssertNil(error) + } + + func test_FactsListViewModel_WhenSearchFactsWithError_ShouldEmmitFactsListError() throws { + let apiError = APIError.statusCode(500) + factsServiceMock.searchFactsReturnValue = .error(apiError) + + let errorObserver = testScheduler.createObserver(FactsListErrorViewModel.self) + + factsListViewModel.outputs.factsListError + .subscribe(errorObserver) + .disposed(by: disposeBag) + + factsListViewModel.inputs.viewDidAppear.onNext(()) + + testScheduler.start() + + let factsListError = errorObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(factsListError?.error.code, apiError.code) + } + + func test_FactsListViewModel_WhenSyncCategoriesWithError_ShouldEmmitFactsListError() throws { + let apiError = APIError.statusCode(500) + factsServiceMock.syncCategoriesReturnValue = .error(apiError) + + let errorObserver = testScheduler.createObserver(FactsListErrorViewModel.self) + + factsListViewModel.outputs.factsListError + .subscribe(errorObserver) + .disposed(by: disposeBag) + + factsListViewModel.inputs.viewDidAppear.onNext(()) + + testScheduler.start() + + let factsListError = errorObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(factsListError?.error.code, apiError.code) + } +} diff --git a/Chuck Norris FactsTests/Scenes/Facts/SearchFacts/SearchFactsViewControllerTests.swift b/Chuck Norris FactsTests/Scenes/Facts/SearchFacts/SearchFactsViewControllerTests.swift new file mode 100644 index 0000000..48e1a9d --- /dev/null +++ b/Chuck Norris FactsTests/Scenes/Facts/SearchFacts/SearchFactsViewControllerTests.swift @@ -0,0 +1,99 @@ +// +// SearchFactsViewControllerTests.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/21/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import XCTest +import RxSwift +import RxBlocking +import RxTest + +@testable import Chuck_Norris_Facts + +class SearchFactsViewControllerTests: XCTestCase { + + var searchFactsViewController: SearchFactsViewController! + var searchFactsViewModel: SearchFactsViewModel! + var factsServiceMock: FactsServiceMock! + var testScheduler: TestScheduler! + var disposeBag: DisposeBag! + + override func setUp() { + disposeBag = DisposeBag() + testScheduler = TestScheduler(initialClock: 0) + factsServiceMock = FactsServiceMock() + searchFactsViewModel = SearchFactsViewModel(factsService: factsServiceMock) + searchFactsViewController = SearchFactsViewController() + searchFactsViewController.viewModel = searchFactsViewModel + + searchFactsViewController.loadViewIfNeeded() + } + + override func tearDown() { + disposeBag = nil + testScheduler = nil + searchFactsViewModel = nil + factsServiceMock = nil + } + + func test_SearchFactsViewController_WhenViewWillAppear_ShouldLoad8Categories() throws { + let stubFactCategories = try stub("get-categories", type: [FactCategory].self) ?? [] + factsServiceMock.retrieveCategoriesReturnValue = .just(stubFactCategories) + + searchFactsViewModel.inputs.viewWillAppear.onNext(()) + + let tableView = searchFactsViewController.tableView + let searchFactsDataSource = tableView.dataSource + let indexPath = IndexPath(row: 0, section: 0) + let suggestionsCell = searchFactsDataSource?.tableView(tableView, cellForRowAt: indexPath) as? SuggestionsCell + + XCTAssertEqual(searchFactsDataSource?.numberOfSections?(in: tableView), 1) + XCTAssertEqual(suggestionsCell?.collectionView.numberOfItems(inSection: 0), 8) + } + + func test_SearchFactsViewController_WhenViewWillAppear_ShouldLoadPastSearches() { + factsServiceMock.retrievePastSearchesReturnValue = .just(["fashion", "games", "explicit"]) + + searchFactsViewModel.inputs.viewWillAppear.onNext(()) + + let tableView = searchFactsViewController.tableView + let searchFactsDataSource = tableView.dataSource + + XCTAssertEqual(searchFactsDataSource?.numberOfSections?(in: tableView), 1) + XCTAssertEqual(searchFactsDataSource?.tableView(tableView, numberOfRowsInSection: 0), 3) + } + + func test_SearchFactsViewController_WhenViewWillAppearWithoutData_ShouldBeEmpty() { + searchFactsViewModel.inputs.viewWillAppear.onNext(()) + + let tableView = searchFactsViewController.tableView + let searchFactsDataSource = tableView.dataSource + + XCTAssertEqual(searchFactsDataSource?.numberOfSections?(in: tableView), 0) + } + + func test_Suggestions_WhenEmpty_ShouldBeHidden() { + factsServiceMock.retrieveCategoriesReturnValue = .just([]) + + searchFactsViewModel.inputs.viewWillAppear.onNext(()) + + let tableView = searchFactsViewController.tableView + let searchFactsDataSource = tableView.dataSource + + XCTAssertEqual(searchFactsDataSource?.numberOfSections?(in: tableView), 0) + } + + func test_PastSearches_WhenEmpty_ShouldBeHidden() { + factsServiceMock.retrievePastSearchesReturnValue = .just([]) + + searchFactsViewModel.inputs.viewWillAppear.onNext(()) + + let tableView = searchFactsViewController.tableView + let searchFactsDataSource = tableView.dataSource + + XCTAssertEqual(searchFactsDataSource?.numberOfSections?(in: tableView), 0) + } +} diff --git a/Chuck Norris FactsTests/Scenes/Facts/SearchFacts/SearchFactsViewModelTests.swift b/Chuck Norris FactsTests/Scenes/Facts/SearchFacts/SearchFactsViewModelTests.swift new file mode 100644 index 0000000..3abb60e --- /dev/null +++ b/Chuck Norris FactsTests/Scenes/Facts/SearchFacts/SearchFactsViewModelTests.swift @@ -0,0 +1,104 @@ +// +// SearchFactsViewModelTests.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import XCTest +import RxSwift +import RxBlocking +import RxTest + +@testable import Chuck_Norris_Facts + +class SearchFactsViewModelTests: XCTestCase { + + var searchFactsViewModel: SearchFactsViewModel! + var factsServiceMock: FactsServiceMock! + var testScheduler: TestScheduler! + var disposeBag: DisposeBag! + + override func setUp() { + disposeBag = DisposeBag() + testScheduler = TestScheduler(initialClock: 0) + factsServiceMock = FactsServiceMock() + searchFactsViewModel = SearchFactsViewModel(factsService: factsServiceMock) + } + + override func tearDown() { + disposeBag = nil + testScheduler = nil + searchFactsViewModel = nil + factsServiceMock = nil + } + + func test_SeachFactsViewModel_WhenSearchFacts_ShouldSetSearchTerm() { + let searchFactsObserver = testScheduler.createObserver(String.self) + + searchFactsViewModel.outputs.didSearchFacts + .subscribe(searchFactsObserver) + .disposed(by: disposeBag) + + searchFactsViewModel.inputs.searchTerm.onNext("games") + searchFactsViewModel.inputs.searchAction.onNext(()) + + testScheduler.start() + + let searchFactsTerm = searchFactsObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(searchFactsTerm, "games") + } + + func test_SearchFactsViewModel_WhenCancelSearch_ShouldCancelSearchScene() { + let cancelObserver = testScheduler.createObserver(Void.self) + + searchFactsViewModel.outputs.didCancel + .subscribe(cancelObserver) + .disposed(by: disposeBag) + + searchFactsViewModel.cancel.onNext(()) + + testScheduler.start() + + let cancelCount = cancelObserver.events.compactMap { $0.value.element }.count + XCTAssertEqual(cancelCount, 1) + } + + func test_SearchFactsViewModel_WhenViewWillAppear_ShouldLoad8RandomSuggestions() throws { + let searchFactsItemsObserver = testScheduler.createObserver([SearchFactsTableViewSection].self) + + let testCategories = try stub("get-categories", type: [FactCategory].self) ?? [] + factsServiceMock.retrieveCategoriesReturnValue = .just(testCategories) + + searchFactsViewModel.outputs.items + .subscribe(searchFactsItemsObserver) + .disposed(by: disposeBag) + + searchFactsViewModel.inputs.viewWillAppear.onNext(()) + + testScheduler.start() + + let searchFactsViewModelEvents = searchFactsItemsObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(searchFactsViewModelEvents?.first?.count, 8) + } + + func test_SearchFactsViewModel_WhenViewWillAppear_ShouldLoadPastSearches() { + let searchFactsItemsObserver = testScheduler.createObserver([SearchFactsTableViewSection].self) + + let pastSearches = ["fashion", "games", "food"] + factsServiceMock.retrievePastSearchesReturnValue = .just(pastSearches) + + searchFactsViewModel.outputs.items + .subscribe(searchFactsItemsObserver) + .disposed(by: disposeBag) + + searchFactsViewModel.inputs.viewWillAppear.onNext(()) + + testScheduler.start() + + let searchFactsViewModelEvents = searchFactsItemsObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(searchFactsViewModelEvents?.count, 1) + XCTAssertEqual(searchFactsViewModelEvents?.first?.items.count, 3) + } +} diff --git a/Chuck Norris FactsTests/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryViewModelTests.swift b/Chuck Norris FactsTests/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryViewModelTests.swift new file mode 100644 index 0000000..4d7d930 --- /dev/null +++ b/Chuck Norris FactsTests/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryViewModelTests.swift @@ -0,0 +1,42 @@ +// +// FactCategoryViewModelTests.swift +// Chuck Norris FactsTests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/31/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import XCTest +import RxSwift +import RxBlocking +import RxTest + +@testable import Chuck_Norris_Facts + +class FactCategoryViewModelTests: XCTestCase { + + func test_FactCategoryViewModel_WhenFormat_ShouldHaveText() throws { + let factCategoryStub = try stub("fact-category", type: FactCategory.self) + let factCategory = try XCTUnwrap(factCategoryStub) + + let factCategoryViewModel = FactCategoryViewModel(category: factCategory) + XCTAssertEqual(factCategoryViewModel.text, factCategory.text) + } + + func test_FactCategoryViewModel_WhenCompare_ShouldBeEquatable() throws { + let factCategoryStub = try stub("fact-category", type: FactCategory.self) + let factCategory = try XCTUnwrap(factCategoryStub) + + let factCategoryViewModelTest = FactCategoryViewModel(category: factCategory) + let factCategoryViewModel = FactCategoryViewModel(category: factCategory) + XCTAssertEqual(factCategoryViewModelTest, factCategoryViewModel) + } + + func test_FactCategoryViewModel_WhenCompare_ShouldBeIdentifiable() throws { + let factCategoryStub = try stub("fact-category", type: FactCategory.self) + let factCategory = try XCTUnwrap(factCategoryStub) + + let factCategoryViewModelTest = FactCategoryViewModel(category: factCategory) + XCTAssertEqual(factCategoryViewModelTest.identity, factCategory.text) + } +} diff --git a/Chuck Norris FactsTests/Stubs/fact-category.json b/Chuck Norris FactsTests/Stubs/fact-category.json new file mode 100644 index 0000000..fc9fd2d --- /dev/null +++ b/Chuck Norris FactsTests/Stubs/fact-category.json @@ -0,0 +1 @@ +"games" diff --git a/Chuck Norris FactsTests/Stubs/facts-list.json b/Chuck Norris FactsTests/Stubs/facts-list.json new file mode 100644 index 0000000..1b7c6d5 --- /dev/null +++ b/Chuck Norris FactsTests/Stubs/facts-list.json @@ -0,0 +1,150 @@ +[ + { + "categories": [ + "movie" + ], + "created_at": "2020-01-05 13:42:19.576875", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "sudkgw_tr_ejehjag7cqwq", + "updated_at": "2020-01-05 13:42:19.576875", + "url": "https:\/\/api.chucknorris.io\/jokes\/sudkgw_tr_ejehjag7cqwq", + "value": "The opening scene of the movie \"Saving Private Ryan\" is loosely based on games of dodgeball Chuck Norris played in second grade." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:19.576875", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "H7lHICEVSsW25ffciJEjxw", + "updated_at": "2020-01-05 13:42:19.576875", + "url": "https:\/\/api.chucknorris.io\/jokes\/H7lHICEVSsW25ffciJEjxw", + "value": "Chuck Norris can play Xbox Kinect games on his PlayStation4 and PlayStation Move games on his Xbox 720." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:20.568859", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "0fvCgPtrRqe3BzC8jxEkUA", + "updated_at": "2020-01-05 13:42:20.568859", + "url": "https:\/\/api.chucknorris.io\/jokes\/0fvCgPtrRqe3BzC8jxEkUA", + "value": "Chuck Norris doesn't need to play games against people to beat their high scores. He just plays with himself and beats every highscore on every game on every console in the whole entire universe." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:21.795084", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "2COz4ZY4SJaM7WKJUmSZ3Q", + "updated_at": "2020-01-05 13:42:21.795084", + "url": "https:\/\/api.chucknorris.io\/jokes\/2COz4ZY4SJaM7WKJUmSZ3Q", + "value": "Michael Phelps currently holds the record for most Olympic gold medals in a single Games with 8. That record will be broken in 2012, when Chuck Norris wins 22." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:23.484083", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "eOcHK252SCmv6T5MsJiexA", + "updated_at": "2020-01-05 13:42:23.484083", + "url": "https:\/\/api.chucknorris.io\/jokes\/eOcHK252SCmv6T5MsJiexA", + "value": "Why did Chuck Norris hasn't appeared on any mortal kombat games. Simple, the name says it all. \"mortal\". Also there won't be any fatality tha will work on him, he will just roundhouse kick anyone either he wins or loose." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:23.484083", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "BUBK6qDSRqWevu0YGEEZvw", + "updated_at": "2020-01-05 13:42:23.484083", + "url": "https:\/\/api.chucknorris.io\/jokes\/BUBK6qDSRqWevu0YGEEZvw", + "value": "Chuck Norris can fight better than all fighting video games. How? He instantly wins." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.099703", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "pYt9of-uQPqyPhk85Z-zUA", + "updated_at": "2020-01-05 13:42:25.099703", + "url": "https:\/\/api.chucknorris.io\/jokes\/pYt9of-uQPqyPhk85Z-zUA", + "value": "If Chuck Norris were a PC or Mac he'd be a Mac because you can't play games with Chuck Norris" + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.628594", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "x6hL23bhTEK03DUlagsIUQ", + "updated_at": "2020-01-05 13:42:25.628594", + "url": "https:\/\/api.chucknorris.io\/jokes\/x6hL23bhTEK03DUlagsIUQ", + "value": "Chuck Norris enjoys playing backyard games with his grandchildren. They often play badminton. But instead of using little sissy racquets & a plastic birdie, they use boat oars & dead chickens." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.905626", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "-j_jS99eTIi7hrDpRQ9qLw", + "updated_at": "2020-01-05 13:42:25.905626", + "url": "https:\/\/api.chucknorris.io\/jokes\/-j_jS99eTIi7hrDpRQ9qLw", + "value": "Chuck Norris is forbidden from competing in paintball games... for very fucking obvious reasons." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.194739", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "20QgKwidT1-ySGoHJCpwSw", + "updated_at": "2020-01-05 13:42:26.194739", + "url": "https:\/\/api.chucknorris.io\/jokes\/20QgKwidT1-ySGoHJCpwSw", + "value": "It's all fun and games until Chuck Norris pulls your eyes out with a socket wrench." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.766831", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "RB2hbqTzTd2ORXy53ITqqQ", + "updated_at": "2020-01-05 13:42:26.766831", + "url": "https:\/\/api.chucknorris.io\/jokes\/RB2hbqTzTd2ORXy53ITqqQ", + "value": "Chuck Norris finished every Call of Duty games in less than 15 minutes..........without shooting a single bullet." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.766831", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "1Iy7_hYKT5GgOfxkYuTK3A", + "updated_at": "2020-01-05 13:42:26.766831", + "url": "https:\/\/api.chucknorris.io\/jokes\/1Iy7_hYKT5GgOfxkYuTK3A", + "value": "Chuck Norris is unstoppable in all games of Call of Duty" + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:27.496799", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "4QsnKWP-QFar62XWvYTTsw", + "updated_at": "2020-01-05 13:42:27.496799", + "url": "https:\/\/api.chucknorris.io\/jokes\/4QsnKWP-QFar62XWvYTTsw", + "value": "Chuck Norris invented the olympic games. with his left pinky." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:28.664997", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "R3xVlG1FR7qySlLqsK5Yjw", + "updated_at": "2020-01-05 13:42:28.664997", + "url": "https:\/\/api.chucknorris.io\/jokes\/R3xVlG1FR7qySlLqsK5Yjw", + "value": "Chuck Norris' last birthday party was held at the La Brea Tar Pits where he enjoyed all of the party games and easily won the 'dunking for dinosaurs' event." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:29.296379", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "baXxcGBqQG6an7udXMTQWA", + "updated_at": "2020-01-05 13:42:29.296379", + "url": "https:\/\/api.chucknorris.io\/jokes\/baXxcGBqQG6an7udXMTQWA", + "value": "When Chuck Norris plays a game, every minute is potentially \"Sudden Death\" for his opponents...including cards and board games." + }, + { + "categories": [ + "celebrity" + ], + "created_at": "2020-01-05 13:42:29.855523", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "l7QlUREJQzOIJVB88DY9jg", + "updated_at": "2020-01-05 13:42:29.855523", + "url": "https:\/\/api.chucknorris.io\/jokes\/l7QlUREJQzOIJVB88DY9jg", + "value": "Chuck Norris was at the X-games getting ready for competition when he got a message from Paris Hilton saying that she had sent him a friend request on MySpace. An infuriated Chuck Norris logged on to MySpace using his skateboard and rejected the request immediately." + } +] diff --git a/Chuck Norris FactsTests/Stubs/get-categories.json b/Chuck Norris FactsTests/Stubs/get-categories.json new file mode 100644 index 0000000..7fd08b8 --- /dev/null +++ b/Chuck Norris FactsTests/Stubs/get-categories.json @@ -0,0 +1 @@ +["animal","career","celebrity","dev","explicit","fashion","food","history","money","movie","music","political","religion","science","sport","travel"] diff --git a/Chuck Norris FactsTests/Stubs/long-fact.json b/Chuck Norris FactsTests/Stubs/long-fact.json new file mode 100644 index 0000000..1f72c25 --- /dev/null +++ b/Chuck Norris FactsTests/Stubs/long-fact.json @@ -0,0 +1,7 @@ +{ + "categories": [], + "icon_url" : "https://assets.chucknorris.host/img/avatar/chuck-norris.png", + "id" : "irY3YudqS1qXxhfWxw12NQ", + "url" : "", + "value" : "Chuck Norris has a chainsaw bayonet attached to the end of his gatling gun. That's how he likes it." +} diff --git a/Chuck Norris FactsTests/Stubs/search-facts.json b/Chuck Norris FactsTests/Stubs/search-facts.json new file mode 100644 index 0000000..3cb93ab --- /dev/null +++ b/Chuck Norris FactsTests/Stubs/search-facts.json @@ -0,0 +1,153 @@ +{ + "total": 16, + "result": [ + { + "categories": [ + "movie" + ], + "created_at": "2020-01-05 13:42:19.576875", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "sudkgw_tr_ejehjag7cqwq", + "updated_at": "2020-01-05 13:42:19.576875", + "url": "https:\/\/api.chucknorris.io\/jokes\/sudkgw_tr_ejehjag7cqwq", + "value": "The opening scene of the movie \"Saving Private Ryan\" is loosely based on games of dodgeball Chuck Norris played in second grade." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:19.576875", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "H7lHICEVSsW25ffciJEjxw", + "updated_at": "2020-01-05 13:42:19.576875", + "url": "https:\/\/api.chucknorris.io\/jokes\/H7lHICEVSsW25ffciJEjxw", + "value": "Chuck Norris can play Xbox Kinect games on his PlayStation4 and PlayStation Move games on his Xbox 720." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:20.568859", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "0fvCgPtrRqe3BzC8jxEkUA", + "updated_at": "2020-01-05 13:42:20.568859", + "url": "https:\/\/api.chucknorris.io\/jokes\/0fvCgPtrRqe3BzC8jxEkUA", + "value": "Chuck Norris doesn't need to play games against people to beat their high scores. He just plays with himself and beats every highscore on every game on every console in the whole entire universe." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:21.795084", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "2COz4ZY4SJaM7WKJUmSZ3Q", + "updated_at": "2020-01-05 13:42:21.795084", + "url": "https:\/\/api.chucknorris.io\/jokes\/2COz4ZY4SJaM7WKJUmSZ3Q", + "value": "Michael Phelps currently holds the record for most Olympic gold medals in a single Games with 8. That record will be broken in 2012, when Chuck Norris wins 22." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:23.484083", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "eOcHK252SCmv6T5MsJiexA", + "updated_at": "2020-01-05 13:42:23.484083", + "url": "https:\/\/api.chucknorris.io\/jokes\/eOcHK252SCmv6T5MsJiexA", + "value": "Why did Chuck Norris hasn't appeared on any mortal kombat games. Simple, the name says it all. \"mortal\". Also there won't be any fatality tha will work on him, he will just roundhouse kick anyone either he wins or loose." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:23.484083", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "BUBK6qDSRqWevu0YGEEZvw", + "updated_at": "2020-01-05 13:42:23.484083", + "url": "https:\/\/api.chucknorris.io\/jokes\/BUBK6qDSRqWevu0YGEEZvw", + "value": "Chuck Norris can fight better than all fighting video games. How? He instantly wins." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.099703", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "pYt9of-uQPqyPhk85Z-zUA", + "updated_at": "2020-01-05 13:42:25.099703", + "url": "https:\/\/api.chucknorris.io\/jokes\/pYt9of-uQPqyPhk85Z-zUA", + "value": "If Chuck Norris were a PC or Mac he'd be a Mac because you can't play games with Chuck Norris" + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.628594", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "x6hL23bhTEK03DUlagsIUQ", + "updated_at": "2020-01-05 13:42:25.628594", + "url": "https:\/\/api.chucknorris.io\/jokes\/x6hL23bhTEK03DUlagsIUQ", + "value": "Chuck Norris enjoys playing backyard games with his grandchildren. They often play badminton. But instead of using little sissy racquets & a plastic birdie, they use boat oars & dead chickens." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:25.905626", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "-j_jS99eTIi7hrDpRQ9qLw", + "updated_at": "2020-01-05 13:42:25.905626", + "url": "https:\/\/api.chucknorris.io\/jokes\/-j_jS99eTIi7hrDpRQ9qLw", + "value": "Chuck Norris is forbidden from competing in paintball games... for very fucking obvious reasons." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.194739", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "20QgKwidT1-ySGoHJCpwSw", + "updated_at": "2020-01-05 13:42:26.194739", + "url": "https:\/\/api.chucknorris.io\/jokes\/20QgKwidT1-ySGoHJCpwSw", + "value": "It's all fun and games until Chuck Norris pulls your eyes out with a socket wrench." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.766831", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "RB2hbqTzTd2ORXy53ITqqQ", + "updated_at": "2020-01-05 13:42:26.766831", + "url": "https:\/\/api.chucknorris.io\/jokes\/RB2hbqTzTd2ORXy53ITqqQ", + "value": "Chuck Norris finished every Call of Duty games in less than 15 minutes..........without shooting a single bullet." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:26.766831", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "1Iy7_hYKT5GgOfxkYuTK3A", + "updated_at": "2020-01-05 13:42:26.766831", + "url": "https:\/\/api.chucknorris.io\/jokes\/1Iy7_hYKT5GgOfxkYuTK3A", + "value": "Chuck Norris is unstoppable in all games of Call of Duty" + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:27.496799", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "4QsnKWP-QFar62XWvYTTsw", + "updated_at": "2020-01-05 13:42:27.496799", + "url": "https:\/\/api.chucknorris.io\/jokes\/4QsnKWP-QFar62XWvYTTsw", + "value": "Chuck Norris invented the olympic games. with his left pinky." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:28.664997", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "R3xVlG1FR7qySlLqsK5Yjw", + "updated_at": "2020-01-05 13:42:28.664997", + "url": "https:\/\/api.chucknorris.io\/jokes\/R3xVlG1FR7qySlLqsK5Yjw", + "value": "Chuck Norris' last birthday party was held at the La Brea Tar Pits where he enjoyed all of the party games and easily won the 'dunking for dinosaurs' event." + }, + { + "categories": [], + "created_at": "2020-01-05 13:42:29.296379", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "baXxcGBqQG6an7udXMTQWA", + "updated_at": "2020-01-05 13:42:29.296379", + "url": "https:\/\/api.chucknorris.io\/jokes\/baXxcGBqQG6an7udXMTQWA", + "value": "When Chuck Norris plays a game, every minute is potentially \"Sudden Death\" for his opponents...including cards and board games." + }, + { + "categories": [ + "celebrity" + ], + "created_at": "2020-01-05 13:42:29.855523", + "icon_url": "https:\/\/assets.chucknorris.host\/img\/avatar\/chuck-norris.png", + "id": "l7QlUREJQzOIJVB88DY9jg", + "updated_at": "2020-01-05 13:42:29.855523", + "url": "https:\/\/api.chucknorris.io\/jokes\/l7QlUREJQzOIJVB88DY9jg", + "value": "Chuck Norris was at the X-games getting ready for competition when he got a message from Paris Hilton saying that she had sent him a friend request on MySpace. An infuriated Chuck Norris logged on to MySpace using his skateboard and rejected the request immediately." + } + ] +} diff --git a/Chuck Norris FactsTests/Stubs/short-fact.json b/Chuck Norris FactsTests/Stubs/short-fact.json new file mode 100644 index 0000000..aa3f8bc --- /dev/null +++ b/Chuck Norris FactsTests/Stubs/short-fact.json @@ -0,0 +1,8 @@ +{ + "categories": [], + "icon_url" : "https://assets.chucknorris.host/img/avatar/chuck-norris.png", + "id" : "gusqVaYoSMKnJ3KKzca3GQ", + "url" : "", + "value" : "Chuck Norris doesn't pay attention, attention pays Chuck Norris." +} + diff --git a/Chuck Norris FactsUITests/Chuck_Norris_FactsUITests.swift b/Chuck Norris FactsUITests/Chuck_Norris_FactsUITests.swift deleted file mode 100644 index cafaed4..0000000 --- a/Chuck Norris FactsUITests/Chuck_Norris_FactsUITests.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// Chuck_Norris_FactsUITests.swift -// Chuck Norris FactsUITests -// -// Created by Djorkaeff Alexandre Vilela Pereira on 10/9/20. -// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. -// - -import XCTest - -class Chuck_Norris_FactsUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // 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() throws { - 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/Chuck Norris FactsUITests/Library/XCUIApplication+LaunchArgument.swift b/Chuck Norris FactsUITests/Library/XCUIApplication+LaunchArgument.swift new file mode 100644 index 0000000..abfbffe --- /dev/null +++ b/Chuck Norris FactsUITests/Library/XCUIApplication+LaunchArgument.swift @@ -0,0 +1,34 @@ +// +// XCUIApplication+LaunchArgument.swift +// Chuck Norris FactsUITests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/29/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import XCTest + +enum LaunchArgument: String { + // UI Testing + case uiTest = "--ui-test" + + // Reset storage + case resetData = "--reset-data" + + // Mock storage data + case mockStorage = "--mock-storage" + + // Mock Http Result + case mockHttp = "--mock-http" + + // Mock Http Error Result + case mockHttpError = "--mock-http-error" +} + +extension XCUIApplication { + // Set Launch Arguments to App Command Line arguments + func setLaunchArguments(_ arguments: [LaunchArgument]) { + launchArguments = arguments.map { $0.rawValue } + } +} diff --git a/Chuck Norris FactsUITests/Scenes/FactsListScene.swift b/Chuck Norris FactsUITests/Scenes/FactsListScene.swift new file mode 100644 index 0000000..8a4ed3d --- /dev/null +++ b/Chuck Norris FactsUITests/Scenes/FactsListScene.swift @@ -0,0 +1,30 @@ +// +// FactsListScene.swift +// Chuck Norris FactsUITests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/12/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import XCTest + +struct FactsListScene { + + let factsTableView: XCUIElement + let emptyListView: XCUIElement + let emptyListLabelView: XCUIElement + let searchButton: XCUIElement + let retryButton: XCUIElement + + init() { + let app = XCUIApplication() + + factsTableView = app.tables["factsTableView"] + emptyListView = app.otherElements["emptyListView"] + emptyListLabelView = app.staticTexts["emptyListLabelView"] + searchButton = app.navigationBars.buttons["searchButton"] + retryButton = app.buttons["retryButton"] + } + +} diff --git a/Chuck Norris FactsUITests/Scenes/SearchFactsScene.swift b/Chuck Norris FactsUITests/Scenes/SearchFactsScene.swift new file mode 100644 index 0000000..a71e9c9 --- /dev/null +++ b/Chuck Norris FactsUITests/Scenes/SearchFactsScene.swift @@ -0,0 +1,36 @@ +// +// SearchFactsScene.swift +// Chuck Norris FactsUITests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import XCTest + +struct SearchFactsScene { + + let searchFactsView: XCUIElement + let searchBarField: XCUIElement + let cancelButton: XCUIElement + let itemsTableView: XCUIElement + + init() { + let app = XCUIApplication() + + searchFactsView = app.otherElements["searchFactsView"] + searchBarField = app.searchFields["Search"] + cancelButton = app.navigationBars.buttons["cancelButton"] + itemsTableView = app.tables["itemsTableView"] + } + +} + +extension SearchFactsScene { + var factCategoryCells: XCUIElementQuery { + itemsTableView.cells + .children(matching: .other) + .matching(identifier: "factCategoryCell") + } +} diff --git a/Chuck Norris FactsUITests/Tests/FactsListUITests.swift b/Chuck Norris FactsUITests/Tests/FactsListUITests.swift new file mode 100644 index 0000000..f5b27ff --- /dev/null +++ b/Chuck Norris FactsUITests/Tests/FactsListUITests.swift @@ -0,0 +1,107 @@ +// +// FactsListTests.swift +// Chuck Norris FactsUITests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/12/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import XCTest + +final class FactsListUITests: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + app = XCUIApplication() + continueAfterFailure = false + } + + func test_FactsList_WhenFirstAccess_ShouldShowEmptyView() throws { + app.setLaunchArguments([.uiTest, .resetData]) + app.launch() + + let factsListScene = FactsListScene() + + XCTAssertTrue(factsListScene.emptyListView.exists) + XCTAssertTrue(factsListScene.emptyListLabelView.exists) + } + + func test_FactsList_WhenShareFact_ShouldShowShareActivity() { + app.setLaunchArguments([.uiTest, .mockStorage, .mockHttp]) + app.launch() + + let factsListScene = FactsListScene() + + let searchFactsScene = SearchFactsScene() + let searchFactsButton = factsListScene.searchButton + XCTAssertTrue(searchFactsButton.exists) + + searchFactsButton.firstMatch.tap() + + searchFactsScene.searchBarField.tap() + searchFactsScene.searchBarField.typeText("games") + + app.keyboards.buttons["Search"].tap() + + let firstFactCell = factsListScene.factsTableView.firstMatch + let shareFactButton = firstFactCell.buttons["shareFactButton"] + XCTAssertTrue(shareFactButton.exists) + + shareFactButton.firstMatch.tap() + + let shareActivity = app.otherElements["ActivityListView"] + XCTAssertTrue(shareActivity.waitForExistence(timeout: 1)) + + let shareActivityClose = shareActivity.buttons["Close"] + shareActivityClose.tap() + + XCTAssertFalse(shareActivity.waitForExistence(timeout: 1)) + } + + func test_FactsList_WhenTapSearch_ShouldShowSearchFacts() { + app.setLaunchArguments([.uiTest, .mockStorage]) + app.launch() + + let factsListScene = FactsListScene() + let searchFactsButton = factsListScene.searchButton + + let searchFactsScene = SearchFactsScene() + let searchFactsView = searchFactsScene.searchFactsView + + XCTAssertTrue(searchFactsButton.exists) + + searchFactsButton.firstMatch.tap() + + XCTAssertTrue(searchFactsView.exists) + } + + func test_FactsList_WhenSearchFails_ShouldShowErrorAlert() { + app.setLaunchArguments([.uiTest, .mockStorage, .mockHttpError]) + app.launch() + + let factsListScene = FactsListScene() + let searchFactsButton = factsListScene.searchButton + + let searchFactsScene = SearchFactsScene() + + XCTAssertTrue(searchFactsButton.exists) + + searchFactsButton.firstMatch.tap() + + searchFactsScene.searchBarField.tap() + searchFactsScene.searchBarField.typeText("games") + + app.keyboards.buttons["Search"].tap() + + XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: 1)) + } + + func test_FactsList_WhenSyncCategories_ShouldShowErrorAlert() { + app.setLaunchArguments([.uiTest, .resetData, .mockHttpError]) + app.launch() + + XCTAssertTrue(app.alerts.firstMatch.waitForExistence(timeout: 1)) + } +} diff --git a/Chuck Norris FactsUITests/Tests/SearchFactsUITests.swift b/Chuck Norris FactsUITests/Tests/SearchFactsUITests.swift new file mode 100644 index 0000000..0f5be7e --- /dev/null +++ b/Chuck Norris FactsUITests/Tests/SearchFactsUITests.swift @@ -0,0 +1,144 @@ +// +// SearchFactsUITests.swift +// Chuck Norris FactsUITests +// +// Created by Djorkaeff Alexandre Vilela Pereira on 10/20/20. +// Copyright © 2020 Djorkaeff Alexandre Vilela Pereira. All rights reserved. +// + +import Foundation +import XCTest + +final class SearchFactsUITests: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + app = XCUIApplication() + continueAfterFailure = false + } + + func test_SearchFacts_WhenSearchByTerm_ShouldLoadFacts() throws { + app.setLaunchArguments([.uiTest, .resetData, .mockStorage, .mockHttp]) + app.launch() + + let factsListScene = FactsListScene() + factsListScene.searchButton.tap() + + let searchFactsScene = SearchFactsScene() + searchFactsScene.searchBarField.tap() + searchFactsScene.searchBarField.typeText("games") + + app.keyboards.buttons["Search"].tap() + + XCTAssertEqual(factsListScene.factsTableView.cells.count, 16) + } + + func test_SearchFacts_WhenCancelSearch_ShouldCancelSearchFacts() throws { + app.setLaunchArguments([.uiTest, .mockStorage]) + app.launch() + + let factsListScene = FactsListScene() + factsListScene.searchButton.tap() + + let searchFactsScene = SearchFactsScene() + searchFactsScene.cancelButton.tap() + + XCTAssertFalse(searchFactsScene.searchFactsView.exists) + } + + func test_SearchFacts_WhenViewAppear_ShouldShow8RandomSuggestions() { + app.setLaunchArguments([.uiTest, .resetData, .mockHttp]) + app.launch() + + let factsListScene = FactsListScene() + factsListScene.searchButton.tap() + + let searchFactsScene = SearchFactsScene() + XCTAssertTrue(searchFactsScene.searchFactsView.exists) + + let suggestionsCells = searchFactsScene.factCategoryCells + + XCTAssertEqual(suggestionsCells.count, 8) + } + + func test_SearchFacts_WhenTapSuggestion_ShouldSearchBySuggestion() { + app.setLaunchArguments([.uiTest, .mockStorage, .mockHttp]) + app.launch() + + let factsListScene = FactsListScene() + factsListScene.searchButton.tap() + + let searchFactsScene = SearchFactsScene() + XCTAssertTrue(searchFactsScene.searchFactsView.exists) + + let suggestionsCells = searchFactsScene.factCategoryCells + + let suggestion = suggestionsCells.firstMatch + XCTAssertTrue(suggestion.exists) + + suggestion.tap() + + XCTAssertGreaterThan(factsListScene.factsTableView.cells.count, 0) + } + + func test_SearchFacts_WhenTapPastSearch_ShouldSearchByPastSearch() { + app.setLaunchArguments([.uiTest, .mockStorage, .mockHttp]) + app.launch() + + let factsListScene = FactsListScene() + factsListScene.searchButton.tap() + + let searchFactsScene = SearchFactsScene() + XCTAssertTrue(searchFactsScene.searchFactsView.exists) + + let searchFactsCells = searchFactsScene.itemsTableView.cells + + let pastSearchCell = searchFactsCells.element(boundBy: 1) + XCTAssertTrue(pastSearchCell.exists) + + pastSearchCell.tap() + + XCTAssertGreaterThan(factsListScene.factsTableView.cells.count, 0) + } + + func test_SearchFacts_WhenTapPastSearch_ShouldOrderPastSearch() { + app.setLaunchArguments([.uiTest, .mockStorage]) + app.launch() + + let factsListScene = FactsListScene() + factsListScene.searchButton.tap() + + let searchFactsScene = SearchFactsScene() + XCTAssertTrue(searchFactsScene.searchFactsView.exists) + + let searchFactsCells = searchFactsScene.itemsTableView.cells + + let secondItem = searchFactsCells.element(boundBy: 2) + XCTAssertTrue(secondItem.exists) + + secondItem.tap() + + factsListScene.searchButton.tap() + + let firstItem = searchFactsCells.element(boundBy: 1) + XCTAssertTrue(firstItem.exists) + + XCTAssertEqual(firstItem.label, secondItem.label) + } + + func test_SearchFacts_WhenFirstAccess_ShouldNoHavePastSearches() { + app.setLaunchArguments([.uiTest, .resetData, .mockHttp]) + app.launch() + + let factsListScene = FactsListScene() + factsListScene.searchButton.tap() + + let searchFactsScene = SearchFactsScene() + XCTAssertTrue(searchFactsScene.searchFactsView.exists) + + let searchFactsCells = searchFactsScene.itemsTableView.cells + + XCTAssertEqual(searchFactsCells.count, 1) + } +} diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..82d1e30 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "fastlane" +gem "cocoapods" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..94cfa47 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,243 @@ +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.381.0) + aws-sdk-core (3.109.1) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.39.0) + aws-sdk-core (~> 3, >= 3.109.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.83.0) + aws-sdk-core (~> 3, >= 3.109.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.2.2) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + 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.162.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) + 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.29.1) + 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.14.0) + 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.1) + 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) + xcodeproj (1.19.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 + +BUNDLED WITH + 2.1.2 diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..89d3d8b --- /dev/null +++ b/Podfile @@ -0,0 +1,39 @@ +platform :ios, '11.0' + +def test_pods + + # Rx + pod 'RxTest' + pod 'RxBlocking' + +end + +target 'Chuck Norris Facts' do + use_frameworks! + + # Rx + pod 'RxSwift' + pod 'RxCocoa' + pod 'RxDataSources' + pod 'RxRealm' + + # Tools + pod 'SwiftLint' + pod 'SwiftGen' + + # UI + pod 'lottie-ios' + + # Storage + pod 'RealmSwift' + + target 'Chuck Norris FactsTests' do + inherit! :search_paths + test_pods + end + + target 'Chuck Norris FactsUITests' do + test_pods + end + +end diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 0000000..e27aa0a --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,74 @@ +PODS: + - Differentiator (4.0.1) + - lottie-ios (3.1.8) + - Realm (5.4.6): + - Realm/Headers (= 5.4.6) + - Realm/Headers (5.4.6) + - RealmSwift (5.4.6): + - Realm (= 5.4.6) + - 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) + - RxRealm (3.1.0): + - RealmSwift (~> 5.2) + - RxSwift (~> 5.0) + - RxRelay (5.1.1): + - RxSwift (~> 5) + - RxSwift (5.1.1) + - RxTest (5.1.1): + - RxSwift (~> 5) + - SwiftGen (6.4.0) + - SwiftLint (0.40.3) + +DEPENDENCIES: + - lottie-ios + - RealmSwift + - RxBlocking + - RxCocoa + - RxDataSources + - RxRealm + - RxSwift + - RxTest + - SwiftGen + - SwiftLint + +SPEC REPOS: + trunk: + - Differentiator + - lottie-ios + - Realm + - RealmSwift + - RxBlocking + - RxCocoa + - RxDataSources + - RxRealm + - RxRelay + - RxSwift + - RxTest + - SwiftGen + - SwiftLint + +SPEC CHECKSUMS: + Differentiator: 886080237d9f87f322641dedbc5be257061b0602 + lottie-ios: 48fac6be217c76937e36e340e2d09cf7b10b7f5f + Realm: bb8d7be40d0bc92f139c47095124513c489c0baf + RealmSwift: c469118d55feccd985f1de12973c6ef5587213ca + RxBlocking: 5f700a78cad61ce253ebd37c9a39b5ccc76477b4 + RxCocoa: 32065309a38d29b5b0db858819b5bf9ef038b601 + RxDataSources: efee07fa4de48477eca0a4611e6d11e2da9c1114 + RxRealm: 50e5fe5c1f22518205afbb313fbc5580d73bc586 + RxRelay: d77f7d771495f43c556cbc43eebd1bb54d01e8e9 + RxSwift: 81470a2074fa8780320ea5fe4102807cb7118178 + RxTest: 711632d5644dffbeb62c936a521b5b008a1e1faa + SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 + SwiftLint: dfd554ff0dff17288ee574814ccdd5cea85d76f7 + +PODFILE CHECKSUM: 39b23e4d93b6e19f465020f3530517a02f6d6898 + +COCOAPODS: 1.9.3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..04bd4d2 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Chuck Norris Facts +Funny facts about Chuck Norris provided by https://api.chucknorris.io. +![Actions Status](https://github.com/djorkaeffalexandre/chuck-norris-facts/workflows/Run%20tests/badge.svg) + +## Screenshots +

+ +

+ +## Getting started + +### Requirements +This application was developed using these tools and no have warranty that it runs on older versions. +- Xcode 11.7 +- Swift 5 +- Cocoapods 1.9.3 + +### How to run +1. You should clone the repository +`$ git clone git@github.com:djorkaeffalexandre/chuck-norris-facts.git` +2. Enter the project folder +`$ cd chuck-norris-facts` +3. Install dependencies +`$ pod install` +4. Open the project on Xcode +`$ open -a Xcode Chuck\ Norris\ Facts.xcworkspace` + +## Architecture +This application conforms to [MVVM-C (Model-View-ViewModel-Coordinators)](https://stevenpcurtis.medium.com/mvvm-c-architecture-with-dependency-injection-testing-3b7197eb2e4d) pattern, +that helps with separation of concerns and allows testing and implementation to be better than [MVC](https://medium.com/swift-coding/mvc-in-swift-a9b1121ab6f0). +This application uses [RxSwift](https://github.com/ReactiveX/RxSwift) which allows reacting to changes even with multiple threads and with lot less code, complexity and bugs. +Using [RxSwift](https://github.com/ReactiveX/RxSwift) and [RxRealm](https://github.com/RxSwiftCommunity/RxRealm) we can listen to database changes and binding them to Views, View Models and View Controllers. + +### Dependencies +- [RxSwift/RxCocoa](https://github.com/ReactiveX/RxSwift) Reactive Programming in Swift. +- [RxDataSources](https://github.com/RxSwiftCommunity/RxDataSources) UITableView and UICollectionView Data Sources for RxSwift. +- [RxRealm](https://github.com/RxSwiftCommunity/RxRealm) RxSwift extension for RealmSwift's types. +- [SwiftLint](https://github.com/realm/SwiftLint) A tool to enforce Swift style and conventions. +- [SwiftGen](https://github.com/SwiftGen/SwiftGen) The Swift code generator for Localizable.strings. +- [Lottie](https://github.com/airbnb/lottie-ios) An iOS library to natively render After Effects vector animations. +- [RealmSwift](https://github.com/realm/realm-cocoa) Realm is a mobile database: a replacement for Core Data & SQLite. + +## Fastlane +We use [Fastlane](https://github.com/fastlane/fastlane) to provide CLI commands to easily build, test and deploy the application on Continuous Integration and Delivery platforms. +You should install [Fastlane](https://github.com/fastlane/fastlane) and it's plugins using `$ bundle install`. + +### Commands +- Run Unit and UI tests (Running on Github Actions) +`$ fastlane ios tests` + +- Release the app to Test Flight (Beta Release) +`$ fastlane ios beta` + +## Contact +Email: djorkaeff7@icloud.com diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..a937f61 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,2 @@ +# app_identifier("net.djorkaeff.Chuck-Norris-Facts") # The bundle identifier of your app +# apple_id("[[APPLE_ID]]") # Your Apple email address diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..0c629b7 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,20 @@ +default_platform(:ios) + +platform :ios do + desc "Run Tests" + lane :tests do + + cocoapods + scan(workspace: "Chuck Norris Facts.xcworkspace", code_coverage: true) + + end + + desc "Build and Deploy Beta" + lane :beta do + + cocoapods + build_app(workspace: "Chuck Norris Facts.xcworkspace") + upload_to_testflight(skip_waiting_for_build_processing: true) + + end +end diff --git a/fastlane/README.md b/fastlane/README.md new file mode 100644 index 0000000..80b6772 --- /dev/null +++ b/fastlane/README.md @@ -0,0 +1,34 @@ +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 tests +``` +fastlane ios tests +``` +Run Tests +### ios beta +``` +fastlane ios beta +``` +Build and Deploy Beta + +---- + +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). diff --git a/swiftgen.yml b/swiftgen.yml new file mode 100644 index 0000000..a9b1e46 --- /dev/null +++ b/swiftgen.yml @@ -0,0 +1,5 @@ +strings: + inputs: Chuck Norris Facts/Resources/Localizable.strings + outputs: + - templateName: structured-swift5 + output: Chuck Norris Facts/Resources/Generated/Strings.swift \ No newline at end of file