diff --git a/Chuck Norris Facts.xcodeproj/project.pbxproj b/Chuck Norris Facts.xcodeproj/project.pbxproj index 4428b46..e64b70e 100644 --- a/Chuck Norris Facts.xcodeproj/project.pbxproj +++ b/Chuck Norris Facts.xcodeproj/project.pbxproj @@ -8,11 +8,9 @@ /* Begin PBXBuildFile section */ 1E049E13254F5A5300226E0B /* RxSwift+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E049E12254F5A5300226E0B /* RxSwift+Extensions.swift */; }; - 1E0E4BA22549F7E30030BC49 /* error.json in Resources */ = {isa = PBXBuildFile; fileRef = 1E0E4BA12549F7E30030BC49 /* error.json */; }; 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 */; }; - 1E15408F2549FA6200675DC4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E15408E2549FA6200675DC4 /* ErrorView.swift */; }; 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 */; }; @@ -33,6 +31,7 @@ 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 */; }; @@ -113,11 +112,9 @@ /* 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 = ""; }; - 1E0E4BA12549F7E30030BC49 /* error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = error.json; 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 = ""; }; - 1E15408E2549FA6200675DC4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; 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 = ""; }; @@ -138,6 +135,7 @@ 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 = ""; }; @@ -280,7 +278,6 @@ children = ( 1E32758E2532A2C0007E838A /* EmptyListView.swift */, 1ED06C942548AAD300139151 /* LoadingView.swift */, - 1E15408E2549FA6200675DC4 /* ErrorView.swift */, ); path = Views; sourceTree = ""; @@ -288,7 +285,6 @@ 1E3275902532A2C4007E838A /* Animations */ = { isa = PBXGroup; children = ( - 1E0E4BA12549F7E30030BC49 /* error.json */, 1EACEC98253649BD0006B36D /* loading.json */, 1E3275912532A2CD007E838A /* empty-box.json */, ); @@ -343,6 +339,15 @@ path = Services; sourceTree = ""; }; + 1E6D568D25505D3F00D27284 /* Error */ = { + isa = PBXGroup; + children = ( + 1EEDC6A4254A408B00D75F3E /* FactsListError.swift */, + 1E6D568E25505D5700D27284 /* FactsListErrorViewModel.swift */, + ); + path = Error; + sourceTree = ""; + }; 1E7F15BA253324760006887B /* Scenes */ = { isa = PBXGroup; children = ( @@ -681,12 +686,12 @@ 1EFE288C25321337008806B9 /* FactsList */ = { isa = PBXGroup; children = ( + 1E6D568D25505D3F00D27284 /* Error */, 1E32758D2532A2A3007E838A /* Views */, 1EFE289325321CB4008806B9 /* Fact */, 1EFE288D2532135B008806B9 /* FactsListViewController.swift */, 1EFE288F2532137C008806B9 /* FactsListCoordinator.swift */, 1EFE2891253214DB008806B9 /* FactsListViewModel.swift */, - 1EEDC6A4254A408B00D75F3E /* FactsListError.swift */, ); path = FactsList; sourceTree = ""; @@ -837,7 +842,6 @@ files = ( 1E135FAF254B52E0009D18AF /* facts.json in Resources */, 1EFE287E25321071008806B9 /* search-facts.json in Resources */, - 1E0E4BA22549F7E30030BC49 /* error.json in Resources */, 1EE0714925314AF600F6BF6D /* LaunchScreen.storyboard in Resources */, 1E5617282540FAF200BF26A0 /* get-categories.json in Resources */, 1EE0714625314AF600F6BF6D /* Assets.xcassets in Resources */, @@ -1059,7 +1063,6 @@ 1EE0713F25314AF500F6BF6D /* SceneDelegate.swift in Sources */, 1E8A0FF42547768500565A86 /* DynamicHeightCollectionView.swift in Sources */, 1EFE288E2532135B008806B9 /* FactsListViewController.swift in Sources */, - 1E15408F2549FA6200675DC4 /* ErrorView.swift in Sources */, 1EEDC6A1254A331500D75F3E /* UICollectionView+Extensions.swift in Sources */, 1E655788254CB13B00950706 /* APIResponse.swift in Sources */, 1EF066E32545CE1D00ECF611 /* PastSearchViewModel.swift in Sources */, @@ -1081,6 +1084,7 @@ 1E135FAA254B4E66009D18AF /* LaunchArgument.swift in Sources */, 1EFE2867253206D3008806B9 /* BaseCoordinator.swift in Sources */, 1E92113B253F90BF00DB340B /* FactCategory.swift in Sources */, + 1E6D568F25505D5700D27284 /* FactsListErrorViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Chuck Norris Facts/App/AppDelegate.swift b/Chuck Norris Facts/App/AppDelegate.swift index 62f35c0..293b0b3 100644 --- a/Chuck Norris Facts/App/AppDelegate.swift +++ b/Chuck Norris Facts/App/AppDelegate.swift @@ -37,7 +37,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if LaunchArgument.check(.mockStorage) { let entities = [ SearchEntity(searchTerm: "games"), - SearchEntity(searchTerm: "fashion") + SearchEntity(searchTerm: "fashion"), + FactCategoryEntity(category: FactCategory(text: "games")), + FactCategoryEntity(category: FactCategory(text: "fashion")) ] let realm = try? Realm() diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListError.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Error/FactsListError.swift similarity index 62% rename from Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListError.swift rename to Chuck Norris Facts/App/Scenes/Facts/FactsList/Error/FactsListError.swift index 6e326e1..5fff962 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListError.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Error/FactsListError.swift @@ -9,10 +9,15 @@ 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): @@ -22,12 +27,23 @@ enum FactsListError { } } + // A code to check where the error come. var code: Int { switch self { case .syncCategories: - return -100 + 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 -101 + return L10n.Errors.cantSearchFacts } } } 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 index af645fb..9e4fe3f 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Fact/FactCell.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Fact/FactCell.swift @@ -9,12 +9,16 @@ import UIKit import RxSwift -class FactCell: UITableViewCell { - - private let categoryView = CategoryView() +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() @@ -73,26 +77,32 @@ class FactCell: UITableViewCell { contentView.clipsToBounds = false contentView.addSubview(shadowView) - shadowView.addSubview(bodyLabel) - shadowView.addSubview(shareButton) - shadowView.addSubview(categoryView) - - shadowView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: padding).isActive = true - shadowView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -padding).isActive = true - shadowView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: padding / 2).isActive = true - shadowView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -padding / 2).isActive = true + 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) + ]) - bodyLabel.topAnchor.constraint(equalTo: shadowView.topAnchor, constant: padding).isActive = true - bodyLabel.leadingAnchor.constraint(equalTo: shadowView.leadingAnchor, constant: padding).isActive = true - bodyLabel.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor, constant: -padding).isActive = true + 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) + ]) - shareButton.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: padding).isActive = true - shareButton.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor, constant: -padding).isActive = true - shareButton.bottomAnchor.constraint(equalTo: shadowView.bottomAnchor, constant: -padding).isActive = true + 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) + ]) - categoryView.translatesAutoresizingMaskIntoConstraints = false - categoryView.centerYAnchor.constraint(equalTo: shareButton.centerYAnchor).isActive = true - categoryView.leftAnchor.constraint(equalTo: shadowView.leftAnchor, constant: padding).isActive = true + shadowView.addSubview(categoryView) + NSLayoutConstraint.activate([ + categoryView.centerYAnchor.constraint(equalTo: shareButton.centerYAnchor), + categoryView.leftAnchor.constraint(equalTo: shadowView.leftAnchor, constant: padding) + ]) } func setup(_ fact: FactViewModel) { diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListCoordinator.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListCoordinator.swift index 2a7b855..1249619 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListCoordinator.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListCoordinator.swift @@ -38,6 +38,15 @@ final class FactsListCoordinator: BaseCoordinator { .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() @@ -65,4 +74,26 @@ final class FactsListCoordinator: BaseCoordinator { } } } + + 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 index 965942a..c71ff14 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewController.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewController.swift @@ -12,14 +12,13 @@ import RxCocoa import RxDataSources import Lottie -class FactsListViewController: UIViewController { +final class FactsListViewController: UIViewController { var viewModel: FactsListViewModel! private let disposeBag = DisposeBag() let tableView = UITableView() - let errorView = ErrorView() let loadingView = LoadingView() let emptyListView = EmptyListView() let searchButton = UIBarButtonItem(barButtonSystemItem: .search, target: nil, action: nil) @@ -46,7 +45,6 @@ class FactsListViewController: UIViewController { setupView() setupBindings() setupTableView() - setupErrorView() setupEmptyListView() setupLoadingView() setupNavigationBar() @@ -63,10 +61,12 @@ class FactsListViewController: UIViewController { tableView.register(FactCell.self) tableView.translatesAutoresizingMaskIntoConstraints = false - tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + 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" } @@ -75,31 +75,24 @@ class FactsListViewController: UIViewController { view.addSubview(loadingView) loadingView.translatesAutoresizingMaskIntoConstraints = false - loadingView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - loadingView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - loadingView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - loadingView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - } - - private func setupErrorView() { - view.addSubview(errorView) - - errorView.isHidden = true - errorView.translatesAutoresizingMaskIntoConstraints = false - errorView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - errorView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - errorView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - errorView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + 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 - emptyListView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - emptyListView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - emptyListView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - emptyListView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + 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() { @@ -149,16 +142,6 @@ class FactsListViewController: UIViewController { emptyListView.searchButton.rx.tap .bind(to: viewModel.inputs.startSearchFacts) .disposed(by: disposeBag) - - errorView.retryButton.rx.tap - .bind(to: viewModel.inputs.retryAction) - .disposed(by: disposeBag) - - viewModel.outputs.errors - .bind(onNext: { [weak self] error in - self?.showErrorView(error) - }) - .disposed(by: disposeBag) } private func showEmptyView(_ listEmpty: Bool, _ searchEmpty: Bool) { @@ -188,13 +171,4 @@ class FactsListViewController: UIViewController { loadingView.stop() } } - - private func showErrorView(_ factsListError: FactsListError) { - emptyListView.isHidden = true - - let localizedErrorDescription = factsListError.error.underlyingError?.localizedDescription - errorView.label.text = localizedErrorDescription ?? L10n.FactListError.serviceUnavailable - errorView.isHidden = false - errorView.play() - } } diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewModel.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewModel.swift index 0fa2478..24402cd 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewModel.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/FactsListViewModel.swift @@ -45,8 +45,8 @@ protocol FactsListViewModelOutputs { // Emmits an ActivityIndicator to check if there is a facts search happening var isLoading: ActivityIndicator { get } - // Emmits an FactsListError to be shown - var errors: Observable { get } + // Emmits an FactsListErrorViewModel to be shown + var factsListError: Observable { get } } final class FactsListViewModel: FactsListViewModelInputs, FactsListViewModelOutputs { @@ -79,7 +79,7 @@ final class FactsListViewModel: FactsListViewModelInputs, FactsListViewModelOutp var isLoading: ActivityIndicator - var errors: Observable + var factsListError: Observable init(factsService: FactsServiceType = FactsService()) { let loadingIndicator = ActivityIndicator() @@ -125,16 +125,15 @@ final class FactsListViewModel: FactsListViewModelInputs, FactsListViewModelOutp .materialize() } - let searchFactsError = searchFacts - .errors() + let searchFactsError = searchFacts.errors() .map { FactsListError.searchFacts($0) } - self.facts = searchFacts - .elements() + self.facts = searchFacts.elements() .map { $0.map { FactViewModel(fact: $0) } } .map { [FactsSectionModel(model: "", items: $0)] } - self.errors = Observable.merge(syncCategoriesError, searchFactsError) + 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 index bd72f77..0ea9a71 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/EmptyListView.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/EmptyListView.swift @@ -14,6 +14,7 @@ final class EmptyListView: UIView { private lazy var animation: AnimationView = { let animation = AnimationView() + animation.translatesAutoresizingMaskIntoConstraints = false animation.animation = Animation.named("empty-box") animation.loopMode = .loop @@ -23,6 +24,7 @@ final class EmptyListView: UIView { lazy var label: UILabel = { let label = UILabel() + label.accessibilityIdentifier = "emptyListLabelView" label.translatesAutoresizingMaskIntoConstraints = false label.font = .preferredFont(forTextStyle: .headline) label.lineBreakMode = .byWordWrapping @@ -36,7 +38,7 @@ final class EmptyListView: UIView { button.translatesAutoresizingMaskIntoConstraints = false button.accessibilityLabel = "Search" - button.setTitle("Search", for: .normal) + button.setTitle(L10n.EmptyView.search, for: .normal) button.titleLabel?.font = .preferredFont(forTextStyle: .body) button.accessibilityIdentifier = "searchButton" @@ -59,26 +61,26 @@ final class EmptyListView: UIView { backgroundColor = .systemBackground addSubview(animation) - - animation.translatesAutoresizingMaskIntoConstraints = false - animation.widthAnchor.constraint(equalToConstant: animationSize).isActive = true - animation.heightAnchor.constraint(equalToConstant: animationSize).isActive = true - animation.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true - animation.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + NSLayoutConstraint.activate([ + animation.widthAnchor.constraint(equalToConstant: animationSize), + animation.heightAnchor.constraint(equalToConstant: animationSize), + animation.centerXAnchor.constraint(equalTo: centerXAnchor), + animation.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) addSubview(label) - - label.translatesAutoresizingMaskIntoConstraints = false - label.topAnchor.constraint(equalTo: animation.bottomAnchor).isActive = true - label.centerXAnchor.constraint(equalTo: animation.centerXAnchor).isActive = true + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: animation.bottomAnchor), + label.centerXAnchor.constraint(equalTo: animation.centerXAnchor) + ]) addSubview(searchButton) - searchButton.translatesAutoresizingMaskIntoConstraints = false - searchButton.topAnchor.constraint(equalTo: label.bottomAnchor).isActive = true - searchButton.centerXAnchor.constraint(equalTo: label.centerXAnchor).isActive = true + NSLayoutConstraint.activate([ + searchButton.topAnchor.constraint(equalTo: label.bottomAnchor), + searchButton.centerXAnchor.constraint(equalTo: label.centerXAnchor) + ]) accessibilityIdentifier = "emptyListView" - label.accessibilityIdentifier = "emptyListLabelView" } func play() { diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/ErrorView.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/ErrorView.swift deleted file mode 100644 index 6c1592b..0000000 --- a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/ErrorView.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// ErrorView.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 -import Lottie - -final class ErrorView: UIView { - - private lazy var animation: AnimationView = { - let loading = AnimationView() - - loading.translatesAutoresizingMaskIntoConstraints = false - loading.animation = Animation.named("error") - - return loading - }() - - lazy var label: UILabel = { - let label = UILabel() - - label.translatesAutoresizingMaskIntoConstraints = false - label.lineBreakMode = .byWordWrapping - label.numberOfLines = 0 - label.textAlignment = .center - label.font = .preferredFont(forTextStyle: .body) - - return label - }() - - lazy var retryButton: UIButton = { - let button = UIButton(type: .system) - - button.translatesAutoresizingMaskIntoConstraints = false - button.accessibilityLabel = "Retry" - button.setTitle("Retry", for: .normal) - button.titleLabel?.font = .preferredFont(forTextStyle: .headline) - button.accessibilityIdentifier = "retryButton" - - return button - }() - - override init(frame: CGRect) { - super.init(frame: frame) - - setupView() - - accessibilityIdentifier = "errorView" - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupView() { - let animationSize: CGFloat = 200 - let padding: CGFloat = 16 - - backgroundColor = .systemBackground - - addSubview(animation) - animation.widthAnchor.constraint(equalToConstant: animationSize).isActive = true - animation.heightAnchor.constraint(equalToConstant: animationSize).isActive = true - animation.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true - animation.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - - addSubview(label) - label.topAnchor.constraint(equalTo: animation.bottomAnchor).isActive = true - label.widthAnchor.constraint(equalTo: widthAnchor, constant: -padding).isActive = true - label.centerXAnchor.constraint(equalTo: animation.centerXAnchor).isActive = true - - addSubview(retryButton) - retryButton.topAnchor.constraint(equalTo: label.bottomAnchor).isActive = true - retryButton.centerXAnchor.constraint(equalTo: label.centerXAnchor).isActive = true - } - - func play() { - animation.play() - } -} diff --git a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/LoadingView.swift b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/LoadingView.swift index 78afce2..0dab464 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/LoadingView.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/FactsList/Views/LoadingView.swift @@ -14,6 +14,7 @@ final class LoadingView: UIView { private lazy var animation: AnimationView = { let loading = AnimationView() + loading.translatesAutoresizingMaskIntoConstraints = false loading.animation = Animation.named("loading") loading.loopMode = .loop @@ -36,11 +37,12 @@ final class LoadingView: UIView { backgroundColor = .systemBackground addSubview(animation) - animation.translatesAutoresizingMaskIntoConstraints = false - animation.widthAnchor.constraint(equalToConstant: animationSize).isActive = true - animation.heightAnchor.constraint(equalToConstant: animationSize).isActive = true - animation.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true - animation.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + NSLayoutConstraint.activate([ + animation.widthAnchor.constraint(equalToConstant: animationSize), + animation.heightAnchor.constraint(equalToConstant: animationSize), + animation.centerXAnchor.constraint(equalTo: centerXAnchor), + animation.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) } func play() { diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchCell.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchCell.swift index 636b8db..8abb4ba 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchCell.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/PastSearch/PastSearchCell.swift @@ -8,7 +8,7 @@ import UIKit -class PastSearchCell: UITableViewCell { +final class PastSearchCell: UITableViewCell { static let identifier = "PastSearchCell" diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsCoordinator.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsCoordinator.swift index 7decb8e..8008375 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsCoordinator.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsCoordinator.swift @@ -14,7 +14,7 @@ enum SearchFactsCoordinationResult { case search(String) } -class SearchFactsCoordinator: BaseCoordinator { +final class SearchFactsCoordinator: BaseCoordinator { private let rootViewController: UIViewController diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewController.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewController.swift index 57dd977..ed4c827 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewController.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/SearchFactsViewController.swift @@ -17,17 +17,19 @@ final class SearchFactsViewController: UIViewController { let disposeBag = DisposeBag() private lazy var itemsDataSource = RxTableViewSectionedReloadDataSource( - configureCell: { dataSource, tableView, indexPath, _ -> UITableViewCell in + 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: self.viewModel.inputs.selectItem) + .bind(to: searchFactsViewModel.inputs.selectItem) .disposed(by: cell.disposeBag) return cell @@ -88,10 +90,12 @@ final class SearchFactsViewController: UIViewController { tableView.backgroundColor = .systemBackground tableView.rowHeight = UITableView.automaticDimension - tableView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + 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) 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 index 517830c..eec5857 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryCell.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryCell.swift @@ -8,9 +8,13 @@ import UIKit -class FactCategoryCell: UICollectionViewCell { +final class FactCategoryCell: UICollectionViewCell { - private let categoryView: CategoryView = CategoryView() + private lazy var categoryView: CategoryView = { + let categoryView = CategoryView() + categoryView.translatesAutoresizingMaskIntoConstraints = false + return categoryView + }() override init(frame: CGRect) { super.init(frame: frame) @@ -32,8 +36,9 @@ class FactCategoryCell: UICollectionViewCell { func setupView() { contentView.addSubview(categoryView) - categoryView.translatesAutoresizingMaskIntoConstraints = false - categoryView.widthAnchor.constraint(equalTo: contentView.widthAnchor).isActive = true - categoryView.heightAnchor.constraint(equalTo: contentView.heightAnchor).isActive = true + 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 index c4a6de3..3c15e44 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryViewModel.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/FactCategory/FactCategoryViewModel.swift @@ -9,7 +9,7 @@ import Foundation import RxDataSources -class FactCategoryViewModel { +final class FactCategoryViewModel { let category: FactCategory let text: String diff --git a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsCell.swift b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsCell.swift index 429f009..4267534 100644 --- a/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsCell.swift +++ b/Chuck Norris Facts/App/Scenes/Facts/SearchFacts/Suggestions/SuggestionsCell.swift @@ -11,7 +11,7 @@ import RxSwift import RxCocoa import RxDataSources -class SuggestionsCell: UITableViewCell { +final class SuggestionsCell: UITableViewCell { override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -61,11 +61,13 @@ class SuggestionsCell: UITableViewCell { }() private func setupView() { - contentView.addSubview(collectionView) - collectionView.backgroundColor = .systemBackground - collectionView.widthAnchor.constraint(equalTo: contentView.widthAnchor).isActive = true - collectionView.heightAnchor.constraint(equalTo: contentView.heightAnchor).isActive = true + + contentView.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.widthAnchor.constraint(equalTo: contentView.widthAnchor), + collectionView.heightAnchor.constraint(equalTo: contentView.heightAnchor) + ]) } private func setupBindings() { diff --git a/Chuck Norris Facts/App/Views/CategoryView.swift b/Chuck Norris Facts/App/Views/CategoryView.swift index 1f631b8..cb35179 100644 --- a/Chuck Norris Facts/App/Views/CategoryView.swift +++ b/Chuck Norris Facts/App/Views/CategoryView.swift @@ -8,7 +8,7 @@ import UIKit -class CategoryView: UIView { +final class CategoryView: UIView { lazy var label: UILabel = { let label = UILabel() @@ -40,8 +40,6 @@ class CategoryView: UIView { backgroundColor = .systemBlue addSubview(label) - - label.translatesAutoresizingMaskIntoConstraints = false 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 diff --git a/Chuck Norris Facts/Core/API/APIError.swift b/Chuck Norris Facts/Core/API/APIError.swift index 4224a6e..8d9758e 100644 --- a/Chuck Norris Facts/Core/API/APIError.swift +++ b/Chuck Norris Facts/Core/API/APIError.swift @@ -8,97 +8,61 @@ import Foundation -/// A type representing possible errors API can throw. -enum APIError: Swift.Error { - - // Indicates a response failed to map to a Decodable object. - case objectMapping(Swift.Error, APIResponse) +protocol APIErrorType: LocalizedError { + var code: Int { get } + var message: String { get } +} - // Indicated data was not received. - case dataMapping(Swift.Error?) +// A type representing possible errors API can throw. +enum APIError: Swift.Error { - // Indicates a response failed with an invalid HTTP status code. - case statusCode(APIResponse) + // Indicates that an Unknown error happened. + case unknown(Swift.Error?) - // Indicates a response failed due to an underlying `Error`. - case underlying(Swift.Error, APIResponse?) + // Indicates data was not received. + case mapping(Swift.Error?) - // Indicates that an `Endpoint` failed to map to a `URLRequest`. - case requestMapping(String) + // Indicates that user doesn't have a network connection. + case noConnection - // Indicates that an `Endpoint` failed to encode the parameters for the `URLRequest`. - case parameterEncoding(Swift.Error) + // Indicates a response failed with an invalid HTTP status code. + case statusCode(Int) - // Indicates that an Unknown error happened - case unknown(Error?) + // Indicates that the network response was not convertible to HTTPURLResponse. + case connectionError } -extension APIError { +extension APIError: APIErrorType { // Code for each error type. var code: Int { switch self { - case .objectMapping: + case .unknown: + return 0 + case .mapping: return 1 - case .dataMapping: + case .noConnection: return 2 case .statusCode: return 3 - case .underlying: + case .connectionError: return 4 - case .requestMapping: - return 5 - case .parameterEncoding: - return 6 - case .unknown: - return 7 } } - // Depending on error type, returns a `Response` object. - var response: APIResponse? { + // A description about the error. + var message: String { switch self { - case .objectMapping: return nil - case .requestMapping: return nil - case .parameterEncoding: return nil - case .statusCode: return nil - case .underlying: return nil - case .dataMapping: return nil - case .unknown: return nil - } - } - - // Depending on error type, returns an underlying `Error`. - var underlyingError: Swift.Error? { - switch self { - case .objectMapping(let error, _): return error - case .statusCode: return nil - case .underlying(let error, _): return error - case .requestMapping: return nil - case .parameterEncoding(let error): return error - case .dataMapping: return nil - case .unknown: return nil - } - } -} - -extension APIError: LocalizedError { - public var errorDescription: String? { - switch self { - case .dataMapping: - return "Failed to read data from request." - case .objectMapping: - return "Failed to map data to a Decodable object." - case .statusCode: - return "Status code didn't fall within the given range." - case .underlying(let error, _): - return error.localizedDescription - case .requestMapping: - return "Failed to map Endpoint to a URLRequest." - case .parameterEncoding(let error): - return "Failed to encode parameters for URLRequest. \(error.localizedDescription)" - case .unknown: - return "Something unexpected happened." + 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." } } } diff --git a/Chuck Norris Facts/Core/API/APIProvider.swift b/Chuck Norris Facts/Core/API/APIProvider.swift index 9d61380..2cb477c 100644 --- a/Chuck Norris Facts/Core/API/APIProvider.swift +++ b/Chuck Norris Facts/Core/API/APIProvider.swift @@ -44,53 +44,37 @@ class APIProvider: APIProviderType { 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 - let response = response as? HTTPURLResponse + // 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 + } - let result = self.convertResponseToResult( - response, - request: request, - data: data, - error: error - ) + // Check if response is a HTTPURLResponse + guard let response = response as? HTTPURLResponse else { + completion(.failure(.connectionError)) + return + } - completion(result) + // Complete with an APIResponse + completion(.success(APIResponse(statusCode: response.statusCode, data: data))) } task.resume() return task } - - // A function responsible for converting the result of a `URLRequest` to a Result. - private func convertResponseToResult( - _ response: HTTPURLResponse?, - request: URLRequest?, - data: Data?, - error: Swift.Error? - ) -> Result { - switch (response, data, error) { - case let (.some(response), data, .none): - let response = APIResponse(statusCode: response.statusCode, data: data ?? Data()) - return .success(response) - case let (.some(response), _, .some(error)): - let response = APIResponse(statusCode: response.statusCode, data: data ?? Data()) - let error = APIError.underlying(error, response) - return .failure(error) - case let (_, _, .some(error)): - let error = APIError.underlying(error, nil) - return .failure(error) - default: - let error = APIError.underlying( - NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: nil), - nil - ) - return .failure(error) - } - } } diff --git a/Chuck Norris Facts/Core/API/APIResponse.swift b/Chuck Norris Facts/Core/API/APIResponse.swift index 91d4bab..1f5cd8a 100644 --- a/Chuck Norris Facts/Core/API/APIResponse.swift +++ b/Chuck Norris Facts/Core/API/APIResponse.swift @@ -12,3 +12,16 @@ 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/Data/Services/FactsService.swift b/Chuck Norris Facts/Core/Data/Services/FactsService.swift index 8405535..b5e0372 100644 --- a/Chuck Norris Facts/Core/Data/Services/FactsService.swift +++ b/Chuck Norris Facts/Core/Data/Services/FactsService.swift @@ -45,6 +45,7 @@ struct FactsService: FactsServiceType { return provider.rx .request(.searchFacts(searchTerm: searchTerm)) .asObservable() + .filterSuccessfulStatusCodes() .observeOn(self.scheduler ?? MainScheduler.asyncInstance) .map(SearchFactsResponse.self, using: JSON.decoder) .map { $0.facts } @@ -61,6 +62,7 @@ struct FactsService: FactsServiceType { return self.provider.rx .request(.getCategories) .asObservable() + .filterSuccessfulStatusCodes() .observeOn(self.scheduler ?? MainScheduler.asyncInstance) .map([FactCategory].self, using: JSON.decoder) .map { self.storage.storeCategories($0) } diff --git a/Chuck Norris Facts/Core/Extensions/API+Rx.swift b/Chuck Norris Facts/Core/Extensions/API+Rx.swift index 422c251..c58d06d 100644 --- a/Chuck Norris Facts/Core/Extensions/API+Rx.swift +++ b/Chuck Norris Facts/Core/Extensions/API+Rx.swift @@ -32,17 +32,23 @@ extension Reactive where Base: APIProviderType { } 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.dataMapping(nil) + throw APIError.mapping(nil) } return Observable.just(try decoder.decode(D.self, from: data)) } catch let error { - throw APIError.objectMapping(error, response) + 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/Library/ActivityIndicator.swift b/Chuck Norris Facts/Core/Library/ActivityIndicator.swift index 9cc47dc..7bfed22 100644 --- a/Chuck Norris Facts/Core/Library/ActivityIndicator.swift +++ b/Chuck Norris Facts/Core/Library/ActivityIndicator.swift @@ -31,15 +31,15 @@ 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. */ -public class ActivityIndicator: SharedSequenceConvertibleType { - public typealias Element = Bool - public typealias SharingStrategy = DriverSharingStrategy +class ActivityIndicator: SharedSequenceConvertibleType { + typealias Element = Bool + typealias SharingStrategy = DriverSharingStrategy private let _lock = NSRecursiveLock() private let _relay = BehaviorRelay(value: 0) private let _loading: SharedSequence - public init() { + init() { _loading = _relay.asDriver() .map { $0 > 0 } .distinctUntilChanged() @@ -68,13 +68,13 @@ public class ActivityIndicator: SharedSequenceConvertibleType { _lock.unlock() } - public func asSharedSequence() -> SharedSequence { + func asSharedSequence() -> SharedSequence { _loading } } extension ObservableConvertibleType { - public func trackActivity(_ activityIndicator: ActivityIndicator) -> Observable { + func trackActivity(_ activityIndicator: ActivityIndicator) -> Observable { activityIndicator.trackActivityOfObservable(self) } } diff --git a/Chuck Norris Facts/Core/Library/DynamicHeightCollectionView.swift b/Chuck Norris Facts/Core/Library/DynamicHeightCollectionView.swift index 619129c..48a3371 100644 --- a/Chuck Norris Facts/Core/Library/DynamicHeightCollectionView.swift +++ b/Chuck Norris Facts/Core/Library/DynamicHeightCollectionView.swift @@ -8,7 +8,7 @@ import UIKit -class DynamicHeightCollectionView: UICollectionView { +final class DynamicHeightCollectionView: UICollectionView { override func layoutSubviews() { super.layoutSubviews() diff --git a/Chuck Norris Facts/Core/Library/JSON.swift b/Chuck Norris Facts/Core/Library/JSON.swift index f2e390a..0c0783f 100644 --- a/Chuck Norris Facts/Core/Library/JSON.swift +++ b/Chuck Norris Facts/Core/Library/JSON.swift @@ -8,14 +8,14 @@ import Foundation -public struct JSON { - public static var decoder: JSONDecoder { +struct JSON { + static var decoder: JSONDecoder { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder } - public static var encoder: JSONEncoder { + static var encoder: JSONEncoder { JSONEncoder() } } diff --git a/Chuck Norris Facts/Resources/Animations/error.json b/Chuck Norris Facts/Resources/Animations/error.json deleted file mode 100644 index 83d5793..0000000 --- a/Chuck Norris Facts/Resources/Animations/error.json +++ /dev/null @@ -1 +0,0 @@ -{"v":"5.0.1","fr":60,"ip":0,"op":67.98,"w":120,"h":140,"ddd":0,"assets":[],"layers":[{"ind":2,"nm":"Layer 2","ks":{"p":{"a":0,"k":[59.997,60.37]},"a":{"a":0,"k":[8.95,-21.138,0]},"s":{"a":1,"k":[{"t":14,"s":[0,0,100],"i":{"x":[0.07],"y":[1]},"o":{"x":[0.86],"y":[0]},"e":[92,92,100]},{"t":63,"s":[92,92,100]}]},"r":{"a":0,"k":0},"o":{"a":1,"k":[{"t":14,"s":[0],"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"e":[100]},{"t":63,"s":[100]}]}},"ao":0,"ip":0,"op":68,"st":0,"bm":0,"sr":1,"ty":4,"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[-0.059,2.139],[0,0],[0,0.147],[2.197,0],[0,-2.285],[0,-0.205],[0,0],[-2.08,0]],"o":[[2.08,0],[0,0],[0,-0.205],[0,-2.285],[-2.197,0],[0,0.147],[0,0],[0.058,2.139],[0,0]],"v":[[8.936,-13.535],[12.217,-16.963],[12.598,-38.613],[12.627,-39.17],[8.965,-42.861],[5.273,-39.17],[5.303,-38.613],[5.684,-16.963],[8.936,-13.535]],"c":true},"hd":false}},{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[0,2.314],[2.402,0],[0,-2.314],[-2.373,0]],"o":[[2.402,0],[0,-2.315],[-2.374,0],[0,2.314],[0,0]],"v":[[8.936,0.586],[13.213,-3.574],[8.936,-7.705],[4.687,-3.574],[8.936,0.586]],"c":true},"hd":false}},{"ty":"fl","c":{"a":0,"k":[0.8,0.8196078431372549,0.8509803921568627,1]},"hd":false,"o":{"a":0,"k":100},"r":1},{"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"Object","hd":false}]},{"ind":1,"nm":"Layer 1","ks":{"p":{"a":0,"k":[60,60.18]},"a":{"a":0,"k":[51,51,0]},"s":{"a":0,"k":[104,104,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"ao":0,"ip":0,"op":68,"st":0,"bm":0,"sr":1,"ty":4,"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"i":[[0,0],[28.167,0],[0,28.167],[-28.166,0],[0,-28.166]],"o":[[0,28.167],[-28.166,0],[0,-28.166],[29,0],[0,0]],"v":[[51,0],[0,51],[-51,0],[0,-51],[51,0]],"c":true},"hd":false}},{"ty":"st","c":{"a":0,"k":[0.8,0.8196078431372549,0.8509803921568627,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":5},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"a":0,"k":[51,51]},"a":{"a":0,"k":[0,0]},"s":{"a":1,"k":[{"t":0,"s":[0,0],"i":{"x":[0.07],"y":[1]},"o":{"x":[0.86],"y":[0]},"e":[105,105]},{"t":49,"s":[105,105],"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"e":[92,92]},{"t":68,"s":[92,92]}]},"r":{"a":0,"k":0},"o":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"e":[100]},{"t":49,"s":[100]}]},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0}}],"nm":"Object","hd":false}]}],"markers":[]} \ No newline at end of file diff --git a/Chuck Norris Facts/Resources/Generated/Strings.swift b/Chuck Norris Facts/Resources/Generated/Strings.swift index 101e3ad..c898466 100644 --- a/Chuck Norris Facts/Resources/Generated/Strings.swift +++ b/Chuck Norris Facts/Resources/Generated/Strings.swift @@ -11,11 +11,27 @@ import Foundation // 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 { @@ -23,13 +39,6 @@ internal enum L10n { internal static let uncategorized = L10n.tr("Localizable", "FactCategory.uncategorized") } - internal enum FactListError { - /// Internet Connection appears to be offline - internal static let noConnection = L10n.tr("Localizable", "FactListError.noConnection") - /// Looks like the Chuck Norris Service is unavailable - internal static let serviceUnavailable = L10n.tr("Localizable", "FactListError.serviceUnavailable") - } - internal enum FactsList { /// Chuck Norris Facts internal static let title = L10n.tr("Localizable", "FactsList.title") diff --git a/Chuck Norris Facts/Resources/Localizable.strings b/Chuck Norris Facts/Resources/Localizable.strings index ff20991..96fbb5c 100644 --- a/Chuck Norris Facts/Resources/Localizable.strings +++ b/Chuck Norris Facts/Resources/Localizable.strings @@ -12,10 +12,14 @@ "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"; -"FactCategory.uncategorized" = "UNCATEGORIZED"; +"Common.oops" = "Oops"; +"Common.ok" = "Ok"; + +"Errors.cantSyncCategories" = "Can't sync categories"; +"Errors.cantSearchFacts" = "Can't search facts"; -"FactListError.noConnection" = "Internet Connection appears to be offline"; -"FactListError.serviceUnavailable" = "Looks like the Chuck Norris Service is unavailable"; +"FactCategory.uncategorized" = "UNCATEGORIZED"; diff --git a/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewControllerTests.swift b/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewControllerTests.swift index cfb2615..0a91b42 100644 --- a/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewControllerTests.swift +++ b/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewControllerTests.swift @@ -57,16 +57,6 @@ class FactsListViewControllerTests: XCTestCase { XCTAssertTrue(factsListViewController.emptyListView.searchButton.isHidden) } - func test_FactsListViewController_WhenThereIsAnError_ShouldShowErrorView() { - let response = APIResponse(statusCode: 500, data: nil) - let apiError = APIError.statusCode(response) - factsServiceMock.searchFactsReturnValue = .error(apiError) - - factsListViewModel.inputs.setSearchTerm.onNext("") - - XCTAssertFalse(factsListViewController.errorView.isHidden) - } - func test_FactCell_WhenContentIsShort_FontSizeShouldBeTitle1() throws { let factStub = try stub("short-fact", type: Fact.self) let fact = try XCTUnwrap(factStub) diff --git a/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewModelTests.swift b/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewModelTests.swift index cc91fc2..5187d85 100644 --- a/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewModelTests.swift +++ b/Chuck Norris FactsTests/Scenes/Facts/FactsList/FactsListViewModelTests.swift @@ -75,9 +75,9 @@ class FactsListViewModelTests: XCTestCase { let categories = try XCTUnwrap(stubCategories) factsServiceMock.retrieveCategoriesReturnValue = .just(categories) - let errorObserver = testScheduler.createObserver(FactsListError.self) + let errorObserver = testScheduler.createObserver(FactsListErrorViewModel.self) - factsListViewModel.outputs.errors + factsListViewModel.outputs.factsListError .subscribe(errorObserver) .disposed(by: disposeBag) @@ -89,14 +89,13 @@ class FactsListViewModelTests: XCTestCase { XCTAssertNil(error) } - func test_FactsListViewModel_WhenSearchFactsWithError_ShouldEmmitFactListError() throws { - let response = APIResponse(statusCode: 500, data: nil) - let apiError = APIError.statusCode(response) + func test_FactsListViewModel_WhenSearchFactsWithError_ShouldEmmitFactsListError() throws { + let apiError = APIError.statusCode(500) factsServiceMock.searchFactsReturnValue = .error(apiError) - let errorObserver = testScheduler.createObserver(FactsListError.self) + let errorObserver = testScheduler.createObserver(FactsListErrorViewModel.self) - factsListViewModel.outputs.errors + factsListViewModel.outputs.factsListError .subscribe(errorObserver) .disposed(by: disposeBag) @@ -104,18 +103,17 @@ class FactsListViewModelTests: XCTestCase { testScheduler.start() - let error = errorObserver.events.compactMap { $0.value.element }.first - XCTAssertEqual(error?.code, FactsListError.searchFacts(apiError).code) + let factsListError = errorObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(factsListError?.error.code, apiError.code) } - func test_FactsListViewModel_WhenSyncCategoriesWithError_ShouldEmmitFactListError() throws { - let response = APIResponse(statusCode: 500, data: nil) - let apiError = APIError.statusCode(response) + func test_FactsListViewModel_WhenSyncCategoriesWithError_ShouldEmmitFactsListError() throws { + let apiError = APIError.statusCode(500) factsServiceMock.syncCategoriesReturnValue = .error(apiError) - let errorObserver = testScheduler.createObserver(FactsListError.self) + let errorObserver = testScheduler.createObserver(FactsListErrorViewModel.self) - factsListViewModel.outputs.errors + factsListViewModel.outputs.factsListError .subscribe(errorObserver) .disposed(by: disposeBag) @@ -123,7 +121,7 @@ class FactsListViewModelTests: XCTestCase { testScheduler.start() - let error = errorObserver.events.compactMap { $0.value.element }.first - XCTAssertEqual(error?.code, FactsListError.syncCategories(apiError).code) + let factsListError = errorObserver.events.compactMap { $0.value.element }.first + XCTAssertEqual(factsListError?.error.code, apiError.code) } } diff --git a/Chuck Norris FactsUITests/Scenes/FactsListScene.swift b/Chuck Norris FactsUITests/Scenes/FactsListScene.swift index ba3452d..8a4ed3d 100644 --- a/Chuck Norris FactsUITests/Scenes/FactsListScene.swift +++ b/Chuck Norris FactsUITests/Scenes/FactsListScene.swift @@ -15,7 +15,6 @@ struct FactsListScene { let emptyListView: XCUIElement let emptyListLabelView: XCUIElement let searchButton: XCUIElement - let errorView: XCUIElement let retryButton: XCUIElement init() { @@ -25,7 +24,6 @@ struct FactsListScene { emptyListView = app.otherElements["emptyListView"] emptyListLabelView = app.staticTexts["emptyListLabelView"] searchButton = app.navigationBars.buttons["searchButton"] - errorView = app.otherElements["errorView"] retryButton = app.buttons["retryButton"] } diff --git a/Chuck Norris FactsUITests/Tests/FactsListUITests.swift b/Chuck Norris FactsUITests/Tests/FactsListUITests.swift index 9700977..f5b27ff 100644 --- a/Chuck Norris FactsUITests/Tests/FactsListUITests.swift +++ b/Chuck Norris FactsUITests/Tests/FactsListUITests.swift @@ -77,8 +77,8 @@ final class FactsListUITests: XCTestCase { XCTAssertTrue(searchFactsView.exists) } - func test_FactsList_WhenSearchFails_ShouldShowErrorView() { - app.setLaunchArguments([.uiTest, .mockHttpError]) + func test_FactsList_WhenSearchFails_ShouldShowErrorAlert() { + app.setLaunchArguments([.uiTest, .mockStorage, .mockHttpError]) app.launch() let factsListScene = FactsListScene() @@ -95,7 +95,13 @@ final class FactsListUITests: XCTestCase { app.keyboards.buttons["Search"].tap() - XCTAssertTrue(factsListScene.errorView.exists) - XCTAssertTrue(factsListScene.retryButton.exists) + 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)) } }