From d0a66b084490e3ed0979f88cad457d5ea77cfdba Mon Sep 17 00:00:00 2001 From: Aleksei Cherepanov Date: Sat, 5 Oct 2019 03:31:58 +0300 Subject: [PATCH 1/4] Favorites added --- ReduxMovieDB.xcodeproj/project.pbxproj | 28 ++++++ ReduxMovieDB/Actions/MainStateAction.swift | 3 + ReduxMovieDB/Base.lproj/Main.storyboard | 38 ++++++++- .../MovieDetailViewController.swift | 24 ++++-- .../Controllers/MovieListViewController.swift | 14 ++- .../Extensions/UIImageView+MoviePoster.swift | 4 +- ReduxMovieDB/State/MainState.swift | 29 +++++-- ReduxMovieDB/Storages/FavoritesStorage.swift | 85 +++++++++++++++++++ .../ViewState/MovieDetailViewState.swift | 11 ++- ReduxMovieDBTests/State/MainStateTests.swift | 5 ++ .../MemoryFavoritesStorageTests.swift | 51 +++++++++++ .../UserDefaultsFavoritesStorageTests.swift | 59 +++++++++++++ .../ViewState/MovieDetailViewStateTests.swift | 14 ++- 13 files changed, 339 insertions(+), 26 deletions(-) create mode 100644 ReduxMovieDB/Storages/FavoritesStorage.swift create mode 100644 ReduxMovieDBTests/Storages/MemoryFavoritesStorageTests.swift create mode 100644 ReduxMovieDBTests/Storages/UserDefaultsFavoritesStorageTests.swift diff --git a/ReduxMovieDB.xcodeproj/project.pbxproj b/ReduxMovieDB.xcodeproj/project.pbxproj index 6c116e2..f24098b 100644 --- a/ReduxMovieDB.xcodeproj/project.pbxproj +++ b/ReduxMovieDB.xcodeproj/project.pbxproj @@ -31,6 +31,9 @@ 8076FB13208B6BFF00513FAC /* MainStateAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8076FB12208B6BFF00513FAC /* MainStateAction.swift */; }; 80BFF62021D4911A00D99771 /* MovieListViewStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80BFF61F21D4911A00D99771 /* MovieListViewStateTests.swift */; }; 8F78D29FD15515E0A0DB290B /* Pods_ReduxMovieDBTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 95329AAEB8BDA5A98456AF4B /* Pods_ReduxMovieDBTests.framework */; }; + 94B596762347FC5600FBECA5 /* FavoritesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B596752347FC5600FBECA5 /* FavoritesStorage.swift */; }; + 94B596792347FD9700FBECA5 /* MemoryFavoritesStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B596782347FD9700FBECA5 /* MemoryFavoritesStorageTests.swift */; }; + 94B5967B234800BE00FBECA5 /* UserDefaultsFavoritesStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B5967A234800BE00FBECA5 /* UserDefaultsFavoritesStorageTests.swift */; }; 94CDE0A723438A6A00545FF1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 94CDE0A923438A6A00545FF1 /* Localizable.strings */; }; /* End PBXBuildFile section */ @@ -73,6 +76,9 @@ 80673CC920323AA70091B48F /* Pages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = ""; }; 8076FB12208B6BFF00513FAC /* MainStateAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStateAction.swift; sourceTree = ""; }; 80BFF61F21D4911A00D99771 /* MovieListViewStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListViewStateTests.swift; sourceTree = ""; }; + 94B596752347FC5600FBECA5 /* FavoritesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStorage.swift; sourceTree = ""; }; + 94B596782347FD9700FBECA5 /* MemoryFavoritesStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryFavoritesStorageTests.swift; sourceTree = ""; }; + 94B5967A234800BE00FBECA5 /* UserDefaultsFavoritesStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsFavoritesStorageTests.swift; sourceTree = ""; }; 94CDE0A823438A6A00545FF1 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 94CDE0AA23438AD700545FF1 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 95329AAEB8BDA5A98456AF4B /* Pods_ReduxMovieDBTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ReduxMovieDBTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -124,6 +130,7 @@ 80673C8B2030C3DA0091B48F /* ReduxMovieDB */ = { isa = PBXGroup; children = ( + 94B596742347FC4000FBECA5 /* Storages */, 8076FB14208B6CBF00513FAC /* API */, 80673CBA2031253F0091B48F /* Model */, 8076FB11208B6BE200513FAC /* Actions */, @@ -145,6 +152,7 @@ 80673CA02030C3DA0091B48F /* ReduxMovieDBTests */ = { isa = PBXGroup; children = ( + 94B596772347FD6700FBECA5 /* Storages */, 80BFF61B21D490CF00D99771 /* State */, 80BFF61E21D490FA00D99771 /* ViewState */, 80673CA32030C3DA0091B48F /* Info.plist */, @@ -241,6 +249,23 @@ name = Frameworks; sourceTree = ""; }; + 94B596742347FC4000FBECA5 /* Storages */ = { + isa = PBXGroup; + children = ( + 94B596752347FC5600FBECA5 /* FavoritesStorage.swift */, + ); + path = Storages; + sourceTree = ""; + }; + 94B596772347FD6700FBECA5 /* Storages */ = { + isa = PBXGroup; + children = ( + 94B596782347FD9700FBECA5 /* MemoryFavoritesStorageTests.swift */, + 94B5967A234800BE00FBECA5 /* UserDefaultsFavoritesStorageTests.swift */, + ); + path = Storages; + sourceTree = ""; + }; D4C24798534322BDDE0E79FC /* Pods */ = { isa = PBXGroup; children = ( @@ -438,6 +463,7 @@ 80673C8F2030C3DA0091B48F /* SplitViewController.swift in Sources */, 800E401F217FA5E300C2A3BD /* Movie+Differentiable.swift in Sources */, 80673C8D2030C3DA0091B48F /* AppDelegate.swift in Sources */, + 94B596762347FC5600FBECA5 /* FavoritesStorage.swift in Sources */, 80673CC620322A6C0091B48F /* Genre.swift in Sources */, 80673CB32030E6090091B48F /* MovieListViewState.swift in Sources */, 80673CB9203125300091B48F /* TMDB.swift in Sources */, @@ -456,6 +482,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 94B5967B234800BE00FBECA5 /* UserDefaultsFavoritesStorageTests.swift in Sources */, + 94B596792347FD9700FBECA5 /* MemoryFavoritesStorageTests.swift in Sources */, 80673CA22030C3DA0091B48F /* MainStateTests.swift in Sources */, 802CD6A221D6A8D2000FF852 /* MovieDetailViewStateTests.swift in Sources */, 80BFF62021D4911A00D99771 /* MovieListViewStateTests.swift in Sources */, diff --git a/ReduxMovieDB/Actions/MainStateAction.swift b/ReduxMovieDB/Actions/MainStateAction.swift index d51f848..76c7008 100644 --- a/ReduxMovieDB/Actions/MainStateAction.swift +++ b/ReduxMovieDB/Actions/MainStateAction.swift @@ -20,4 +20,7 @@ enum MainStateAction: Action { case readySearch case search(String) case cancelSearch + + case toggleShowFavorites + case toggleFavoriteMovie } diff --git a/ReduxMovieDB/Base.lproj/Main.storyboard b/ReduxMovieDB/Base.lproj/Main.storyboard index 8d2ab8b..1f773e8 100644 --- a/ReduxMovieDB/Base.lproj/Main.storyboard +++ b/ReduxMovieDB/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -81,7 +81,17 @@ + + + + + @@ -97,6 +107,7 @@ + @@ -116,8 +127,15 @@ - + + + + + + + + @@ -176,13 +194,26 @@ + + + @@ -302,6 +333,7 @@ + diff --git a/ReduxMovieDB/Controllers/MovieDetailViewController.swift b/ReduxMovieDB/Controllers/MovieDetailViewController.swift index 2742614..6b13060 100644 --- a/ReduxMovieDB/Controllers/MovieDetailViewController.swift +++ b/ReduxMovieDB/Controllers/MovieDetailViewController.swift @@ -22,7 +22,8 @@ class MovieDetailViewController: UITableViewController { @IBOutlet weak var genreValue: UILabel! @IBOutlet weak var overviewLabel: UILabel! @IBOutlet weak var overviewValue: UILabel! - + @IBOutlet weak var favoriteButton: UIButton! + @IBOutlet weak var posterImageView: UIImageView! private let posterViewHeight: CGFloat = 300 @@ -78,7 +79,12 @@ class MovieDetailViewController: UITableViewController { return defaultHeight } - + @IBAction func toggleFavorite(_ sender: Any) { + guard let id = (sender as? UIButton)?.tag else { return } + guard id >= 0 else { return } + mainStore.dispatch(MainStateAction.toggleFavoriteMovie) + } + func setupPosterView() { posterView = tableView.tableHeaderView tableView.tableHeaderView = nil @@ -107,6 +113,7 @@ class MovieDetailViewController: UITableViewController { genreLabel.text = NSLocalizedString("GENRE", comment: "Film genre") overviewLabel.text = NSLocalizedString("OVERVIEW", comment: "Film overview") } + } // MARK: StoreSubscriber @@ -122,13 +129,16 @@ extension MovieDetailViewController: StoreSubscriber { releaseDateValue.text = state.date genreValue.text = state.genres overviewValue.text = state.overview + + if let isFavorite = state.isFavorite { + favoriteButton.setTitle(isFavorite ? "★" : "☆", for: .normal) + favoriteButton.isEnabled = true + } else { + favoriteButton.isEnabled = false + } tableView.endUpdates() - if let movie = state.movie { - posterImageView.setPosterForMovie(movie) - } else { - posterImageView.image = nil - } + posterImageView.setPosterForMovie(state.poster) } } diff --git a/ReduxMovieDB/Controllers/MovieListViewController.swift b/ReduxMovieDB/Controllers/MovieListViewController.swift index d543345..f6017e5 100644 --- a/ReduxMovieDB/Controllers/MovieListViewController.swift +++ b/ReduxMovieDB/Controllers/MovieListViewController.swift @@ -70,6 +70,8 @@ class MovieListViewController: UIViewController { .disposed(by: disposeBag) } } + + @IBOutlet weak var favoritesToggleItem: UIBarButtonItem! override func viewDidLoad() { super.viewDidLoad() @@ -91,6 +93,10 @@ class MovieListViewController: UIViewController { super.viewWillDisappear(animated) mainStore.unsubscribe(self) } + + @IBAction func onToggleFavorites(_ sender: Any) { + mainStore.dispatch(MainStateAction.toggleShowFavorites) + } } // MARK: StoreSubscriber @@ -120,7 +126,8 @@ class MovieListTableViewCell: UITableViewCell { @IBOutlet weak var icon: UIImageView! @IBOutlet weak var title: UILabel! @IBOutlet weak var subtitle: UILabel! - + @IBOutlet weak var isFavorite: UILabel! + override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) @@ -131,9 +138,12 @@ class MovieListTableViewCell: UITableViewCell { didSet { guard let movie = movie else { return } - icon.setPosterForMovie(movie) + icon.setPosterForMovie(movie.posterPath) title.text = movie.title subtitle.text = movie.releaseDate?.description ?? "" + + let isFavorite = movie.id.map { favoritesStore.isFavorite(id: $0) } ?? false + self.isFavorite.isHidden = !isFavorite } } } diff --git a/ReduxMovieDB/Extensions/UIImageView+MoviePoster.swift b/ReduxMovieDB/Extensions/UIImageView+MoviePoster.swift index 16a7513..fec1285 100644 --- a/ReduxMovieDB/Extensions/UIImageView+MoviePoster.swift +++ b/ReduxMovieDB/Extensions/UIImageView+MoviePoster.swift @@ -59,10 +59,10 @@ extension UIImageView { return "https://image.tmdb.org/t/p/w500" } - func setPosterForMovie(_ movie: Movie) { + func setPosterForMovie(_ path: String?) { let placeholder = UIImage(named: "poster_placeholder") - guard let posterPath = movie.posterPath, + guard let posterPath = path, let imageURL = URL(string: "\(imageBaseUrl)\(posterPath)") else { image = placeholder return diff --git a/ReduxMovieDB/State/MainState.swift b/ReduxMovieDB/State/MainState.swift index abf16e5..f56d912 100644 --- a/ReduxMovieDB/State/MainState.swift +++ b/ReduxMovieDB/State/MainState.swift @@ -22,12 +22,10 @@ enum MovieDetailState: Equatable { var movie: Movie? { switch self { - case .willHide(let movie): + case .willHide(let movie), .show(let movie): return movie case .hide: return nil - case .show(let movie): - return movie } } } @@ -35,13 +33,24 @@ enum MovieDetailState: Equatable { struct MainState: StateType, Equatable { var genres: [Genre] = [] var moviePages: Pages = Pages() - var movieDetail: MovieDetailState = .hide - + var favorites: [Movie] { return favoritesStore.favorites } var search: SearchState = .canceled + var isFavoritesList: Bool = false var movies: [Movie] { - return moviePages.values + return isFavoritesList ? favorites : moviePages.values + } + + func toggleFavorite() { + guard let movie = movieDetail.movie else { return } + favoritesStore.toggle(movie: movie) + } +} + +extension MainState { + func isFavorite(id: Int) -> Bool { + return favoritesStore.isFavorite(id: id) } } @@ -75,9 +84,16 @@ func mainReducer(action: Action, state: MainState?) -> MainState { case .readySearch: state.moviePages = Pages() state.search = .ready + state.isFavoritesList = false case .search(let query): state.moviePages = Pages() state.search = .searching(query) + state.isFavoritesList = false + + case .toggleShowFavorites: + state.isFavoritesList.toggle() + case .toggleFavoriteMovie: + state.toggleFavorite() } return state @@ -90,3 +106,4 @@ let mainStore = Store( state: MainState(), middleware: [thunksMiddleware] ) +let favoritesStore: FavoritesStorage = UserDefaultsFavoritesStorage(defaults: UserDefaults.standard, key: "favorite_movies") diff --git a/ReduxMovieDB/Storages/FavoritesStorage.swift b/ReduxMovieDB/Storages/FavoritesStorage.swift new file mode 100644 index 0000000..3261942 --- /dev/null +++ b/ReduxMovieDB/Storages/FavoritesStorage.swift @@ -0,0 +1,85 @@ +// +// FavoritesStorage.swift +// ReduxMovieDB +// +// Created by Aleksei Cherepanov on 05.10.2019. +// Copyright © 2019 Matheus Cardoso. All rights reserved. +// + +import Foundation + +protocol FavoritesStorage { + var favorites: [Movie] { get } + func isFavorite(id: Int) -> Bool + @discardableResult func toggle(movie: Movie) -> Bool + func drop() +} + +class MemoryFavoritesStorage: FavoritesStorage { + + var storage = [Int: Movie]() + var favorites: [Movie] { + get { + return storage.map { $0.value } + } + set { + storage = .init(newValue.map { ($0.id!, $0) }, + uniquingKeysWith: { first, _ in return first }) + } + } + + func isFavorite(id: Int) -> Bool { + return storage[id] != nil + } + + func toggle(movie: Movie) -> Bool { + guard let id = movie.id else { return false } + if isFavorite(id: id) { + storage.removeValue(forKey: id) + return false + } else { + storage[id] = movie + return true + } + } + + func drop() { + storage = [Int: Movie]() + } +} + +class UserDefaultsFavoritesStorage: MemoryFavoritesStorage { + override var storage: [Int: Movie] { + didSet { save() } + } + var defaults: UserDefaults + var storageKey: String + + init(defaults: UserDefaults, key: String) { + self.defaults = defaults + self.storageKey = key + super.init() + load() + } + + /// Load favorites from UserDefaults + func load() { + guard let saved = defaults.object(forKey: storageKey) as? Data else { return } + let decoder = JSONDecoder() + guard let loaded = try? decoder.decode(Array.self, from: saved) else { return } + favorites = loaded + } + + /// Save favorites from UserDefaults + func save() { + let encoder = JSONEncoder() + do { + let encoded = try encoder.encode(favorites) + defaults.set(encoded, forKey: storageKey) + } catch { + print(error.localizedDescription) + } + // guard let encoded = try? encoder.encode(favorites) else { return } + + } +} diff --git a/ReduxMovieDB/ViewState/MovieDetailViewState.swift b/ReduxMovieDB/ViewState/MovieDetailViewState.swift index 95a1914..45db235 100644 --- a/ReduxMovieDB/ViewState/MovieDetailViewState.swift +++ b/ReduxMovieDB/ViewState/MovieDetailViewState.swift @@ -9,19 +9,24 @@ import Foundation struct MovieDetailViewState { - let movie: Movie? - + let id: Int? + let poster: String? let title: String let date: String let genres: String let overview: String + let isFavorite: Bool? init(_ state: MainState) { - movie = state.movieDetail.movie + let movie = state.movieDetail.movie + + id = movie?.id + poster = movie?.posterPath date = movie?.releaseDate ?? NSLocalizedString("NO_RELEASE_DATE", comment: "Release date empty message") title = movie?.title ?? NSLocalizedString("NO_TITLE", comment: "Title empty message") overview = movie?.overview ?? NSLocalizedString("NO_OVERVIEW", comment: "Overview date empty message") genres = localizedGenres(movie?.genreIds ?? [], state.genres) + isFavorite = movie?.id.map { state.isFavorite(id: $0) } ?? nil } } diff --git a/ReduxMovieDBTests/State/MainStateTests.swift b/ReduxMovieDBTests/State/MainStateTests.swift index 4f24d65..5c705a5 100644 --- a/ReduxMovieDBTests/State/MainStateTests.swift +++ b/ReduxMovieDBTests/State/MainStateTests.swift @@ -25,6 +25,7 @@ class ReduxMovieDBTests: XCTestCase { case .readySearch: return testReadySearch case .search(_): return testSearch case .cancelSearch: return testCancelSearch + case .toggleFavoriteMovie: return testToggleFavoriteMovie } } @@ -154,4 +155,8 @@ class ReduxMovieDBTests: XCTestCase { } } + func testToggleFavoriteMovie() { + + } + } diff --git a/ReduxMovieDBTests/Storages/MemoryFavoritesStorageTests.swift b/ReduxMovieDBTests/Storages/MemoryFavoritesStorageTests.swift new file mode 100644 index 0000000..49fa3d5 --- /dev/null +++ b/ReduxMovieDBTests/Storages/MemoryFavoritesStorageTests.swift @@ -0,0 +1,51 @@ +// +// MemoryFavoritesStorageTests.swift +// ReduxMovieDBTests +// +// Created by Aleksei Cherepanov on 05.10.2019. +// Copyright © 2019 Matheus Cardoso. All rights reserved. +// + +import XCTest +@testable import ReduxMovieDB + +class MemoryFavoritesStorageTests: XCTestCase { + + var storage: MemoryFavoritesStorage! + + override func setUp() { + super.setUp() + storage = MemoryFavoritesStorage() + } + + func testIsFavorite() { + XCTAssertFalse(storage.isFavorite(id: 3)) + storage.storage = [3: makeMovie(3)] + XCTAssertTrue(storage.isFavorite(id: 3)) + } + + func testToggleItem() { + XCTAssertTrue(storage.toggle(movie: makeMovie(2))) + XCTAssertEqual(storage.favorites, [makeMovie(2)]) + XCTAssertFalse(storage.toggle(movie: makeMovie(2))) + XCTAssertTrue(storage.favorites.isEmpty) + } + + func testDrop() { + XCTAssertTrue(storage.favorites.isEmpty) + storage.storage = [1: makeMovie(1), 2: makeMovie(2), 3: makeMovie(3)] + storage.drop() + XCTAssertTrue(storage.favorites.isEmpty) + } +} + +func makeMovie(_ id: Int) -> Movie { + return Movie( + id: id, + title: "title_\(id)", + releaseDate: "releaseDate_\(id)", + posterPath: "posterPath_\(id)", + genreIds: [], + overview: "overview_\(id)" + ) +} diff --git a/ReduxMovieDBTests/Storages/UserDefaultsFavoritesStorageTests.swift b/ReduxMovieDBTests/Storages/UserDefaultsFavoritesStorageTests.swift new file mode 100644 index 0000000..95e6547 --- /dev/null +++ b/ReduxMovieDBTests/Storages/UserDefaultsFavoritesStorageTests.swift @@ -0,0 +1,59 @@ +// +// UserDefaultsFavoritesStorageTests.swift +// ReduxMovieDBTests +// +// Created by Aleksei Cherepanov on 05.10.2019. +// Copyright © 2019 Matheus Cardoso. All rights reserved. +// + +import XCTest +@testable import ReduxMovieDB + +class UserDefaultsFavoritesStorageTests: XCTestCase { + + var storage: UserDefaultsFavoritesStorage! + let defaults = MockDefaults() + + override func setUp() { + storage = .init(defaults: defaults, key: "favorites") + } + + override func tearDown() { + defaults.drop() + } + + func testLoad() { + let json = "[{\"id\":1,\"title\":\"title_1\",\"release_date\":\"releaseDate_1\",\"poster_path\":\"posterPath_1\",\"genre_ids\":[],\"overview\":\"overview_1\"}]" + defaults.set(json.data(using: .utf8), forKey: "favorites") + XCTAssertTrue(storage.favorites.isEmpty) + storage.load() + XCTAssertEqual(storage.storage[1], makeMovie(1)) + } + + func testSave() { + let empty = defaults.data(forKey: "favorites") + XCTAssertNil(empty) + storage.storage = [1: makeMovie(1)] + let saved = defaults.data(forKey: "favorites") + let result = String(data: saved!, encoding: .utf8) + let json = "[{\"id\":1,\"title\":\"title_1\",\"release_date\":\"releaseDate_1\",\"poster_path\":\"posterPath_1\",\"genre_ids\":[],\"overview\":\"overview_1\"}]" + XCTAssertEqual(result, json) + } + + func testDrop() { + defaults.set([1, 2, 3], forKey: "favorites") + storage.drop() + let empty = defaults.array(forKey: "favorites") as? [Int] ?? [] + XCTAssertTrue(empty.isEmpty) + } + + class MockDefaults: UserDefaults { + init() { + super.init(suiteName: "MockDefaults")! + } + + func drop() { + removePersistentDomain(forName: "MockDefaults") + } + } +} diff --git a/ReduxMovieDBTests/ViewState/MovieDetailViewStateTests.swift b/ReduxMovieDBTests/ViewState/MovieDetailViewStateTests.swift index 7a522c8..f26b5f7 100644 --- a/ReduxMovieDBTests/ViewState/MovieDetailViewStateTests.swift +++ b/ReduxMovieDBTests/ViewState/MovieDetailViewStateTests.swift @@ -27,7 +27,11 @@ class MovieDetailViewStateTests: XCTestCase { state.movieDetail = .willHide(movie) let viewState = MovieDetailViewState(state) - XCTAssertEqual(viewState.movie, movie) + XCTAssertEqual(viewState.id, 0) + XCTAssertEqual(viewState.poster, "posterPath") + XCTAssertEqual(viewState.title, "title") + XCTAssertEqual(viewState.date, "releaseDate") + XCTAssertEqual(viewState.overview, "overview") } func testInitWithMovieDetailHide() { @@ -36,7 +40,7 @@ class MovieDetailViewStateTests: XCTestCase { state.movieDetail = .hide let viewState = MovieDetailViewState(state) - XCTAssertNil(viewState.movie) + XCTAssertNil(viewState.id) } func testInitWithMovieDetailShow() { @@ -54,7 +58,11 @@ class MovieDetailViewStateTests: XCTestCase { state.movieDetail = .show(movie) let viewState = MovieDetailViewState(state) - XCTAssertEqual(viewState.movie, movie) + XCTAssertEqual(viewState.id, 0) + XCTAssertEqual(viewState.poster, "posterPath") + XCTAssertEqual(viewState.title, "title") + XCTAssertEqual(viewState.date, "releaseDate") + XCTAssertEqual(viewState.overview, "overview") } func testLocalizedGenres() { From b66cb71c7775f333b3e5592da8fb35dbd435c23d Mon Sep 17 00:00:00 2001 From: Aleksei Cherepanov Date: Mon, 7 Oct 2019 02:13:56 +0300 Subject: [PATCH 2/4] resolved #18 --- ReduxMovieDB/Controllers/MovieDetailViewController.swift | 3 +-- ReduxMovieDB/State/MainState.swift | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ReduxMovieDB/Controllers/MovieDetailViewController.swift b/ReduxMovieDB/Controllers/MovieDetailViewController.swift index 6b13060..3301515 100644 --- a/ReduxMovieDB/Controllers/MovieDetailViewController.swift +++ b/ReduxMovieDB/Controllers/MovieDetailViewController.swift @@ -80,9 +80,8 @@ class MovieDetailViewController: UITableViewController { return defaultHeight } @IBAction func toggleFavorite(_ sender: Any) { - guard let id = (sender as? UIButton)?.tag else { return } - guard id >= 0 else { return } mainStore.dispatch(MainStateAction.toggleFavoriteMovie) + favoriteButton.setTitle(mainStore.state.isCurrentFavorite ? "★" : "☆", for: .normal) } func setupPosterView() { diff --git a/ReduxMovieDB/State/MainState.swift b/ReduxMovieDB/State/MainState.swift index f56d912..a1e44d3 100644 --- a/ReduxMovieDB/State/MainState.swift +++ b/ReduxMovieDB/State/MainState.swift @@ -34,7 +34,8 @@ struct MainState: StateType, Equatable { var genres: [Genre] = [] var moviePages: Pages = Pages() var movieDetail: MovieDetailState = .hide - var favorites: [Movie] { return favoritesStore.favorites } + var isCurrentFavorite: Bool { movieDetail.movie?.id.map(isFavorite) ?? false } + var favorites: [Movie] { favoritesStore.favorites } var search: SearchState = .canceled var isFavoritesList: Bool = false From 807a5195008371fc0403dc5f99f2eecbca96173a Mon Sep 17 00:00:00 2001 From: Aleksei Cherepanov Date: Mon, 7 Oct 2019 02:26:39 +0300 Subject: [PATCH 3/4] Blank test method body for missed action added --- ReduxMovieDBTests/State/MainStateTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ReduxMovieDBTests/State/MainStateTests.swift b/ReduxMovieDBTests/State/MainStateTests.swift index 5c705a5..cf394a8 100644 --- a/ReduxMovieDBTests/State/MainStateTests.swift +++ b/ReduxMovieDBTests/State/MainStateTests.swift @@ -26,6 +26,7 @@ class ReduxMovieDBTests: XCTestCase { case .search(_): return testSearch case .cancelSearch: return testCancelSearch case .toggleFavoriteMovie: return testToggleFavoriteMovie + case .toggleShowFavorites: return toggleShowFavorites } } @@ -155,8 +156,7 @@ class ReduxMovieDBTests: XCTestCase { } } - func testToggleFavoriteMovie() { - - } + func testToggleFavoriteMovie() {} + func toggleShowFavorites() {} } From 1b300b8fb7968e4c79bc052a7e429a50f05d1aed Mon Sep 17 00:00:00 2001 From: Aleksei Cherepanov Date: Fri, 18 Oct 2019 00:10:52 +0300 Subject: [PATCH 4/4] Buttons actions to rx methods replaced. Action unit tests added --- ReduxMovieDB.xcodeproj/project.pbxproj | 4 +++ ReduxMovieDB/Base.lproj/Main.storyboard | 10 +------ .../MovieDetailViewController.swift | 20 +++++++++----- .../Controllers/MovieListViewController.swift | 17 ++++++++---- ReduxMovieDB/Extensions/Boolean.swift | 13 ++++++++++ ReduxMovieDBTests/State/MainStateTests.swift | 26 ++++++++++++++++--- 6 files changed, 66 insertions(+), 24 deletions(-) create mode 100644 ReduxMovieDB/Extensions/Boolean.swift diff --git a/ReduxMovieDB.xcodeproj/project.pbxproj b/ReduxMovieDB.xcodeproj/project.pbxproj index 143d9a2..d8dabb6 100644 --- a/ReduxMovieDB.xcodeproj/project.pbxproj +++ b/ReduxMovieDB.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 8076FB13208B6BFF00513FAC /* MainStateAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8076FB12208B6BFF00513FAC /* MainStateAction.swift */; }; 80BFF62021D4911A00D99771 /* MovieListViewStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80BFF61F21D4911A00D99771 /* MovieListViewStateTests.swift */; }; 8F78D29FD15515E0A0DB290B /* Pods_ReduxMovieDBTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 95329AAEB8BDA5A98456AF4B /* Pods_ReduxMovieDBTests.framework */; }; + 946FA2FA23590C9D00ABB62E /* Boolean.swift in Sources */ = {isa = PBXBuildFile; fileRef = 946FA2F923590C9D00ABB62E /* Boolean.swift */; }; 94B596762347FC5600FBECA5 /* FavoritesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B596752347FC5600FBECA5 /* FavoritesStorage.swift */; }; 94B596792347FD9700FBECA5 /* MemoryFavoritesStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B596782347FD9700FBECA5 /* MemoryFavoritesStorageTests.swift */; }; 94B5967B234800BE00FBECA5 /* UserDefaultsFavoritesStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B5967A234800BE00FBECA5 /* UserDefaultsFavoritesStorageTests.swift */; }; @@ -76,6 +77,7 @@ 80673CC920323AA70091B48F /* Pages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pages.swift; sourceTree = ""; }; 8076FB12208B6BFF00513FAC /* MainStateAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainStateAction.swift; sourceTree = ""; }; 80BFF61F21D4911A00D99771 /* MovieListViewStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListViewStateTests.swift; sourceTree = ""; }; + 946FA2F923590C9D00ABB62E /* Boolean.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Boolean.swift; sourceTree = ""; }; 94B596752347FC5600FBECA5 /* FavoritesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesStorage.swift; sourceTree = ""; }; 94B596782347FD9700FBECA5 /* MemoryFavoritesStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryFavoritesStorageTests.swift; sourceTree = ""; }; 94B5967A234800BE00FBECA5 /* UserDefaultsFavoritesStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsFavoritesStorageTests.swift; sourceTree = ""; }; @@ -186,6 +188,7 @@ children = ( 80673CBD20313EA20091B48F /* UIImageView+MoviePoster.swift */, 805B438B21989719004A7C28 /* UITableView+DiffUpdate.swift */, + 946FA2F923590C9D00ABB62E /* Boolean.swift */, ); path = Extensions; sourceTree = ""; @@ -467,6 +470,7 @@ 80673CC620322A6C0091B48F /* Genre.swift in Sources */, 80673CB32030E6090091B48F /* MovieListViewState.swift in Sources */, 80673CB9203125300091B48F /* TMDB.swift in Sources */, + 946FA2FA23590C9D00ABB62E /* Boolean.swift in Sources */, 80673CB72030F5FC0091B48F /* MovieDetailViewState.swift in Sources */, 80673CB52030F5430091B48F /* MovieDetailViewController.swift in Sources */, 80673CBE20313EA20091B48F /* UIImageView+MoviePoster.swift in Sources */, diff --git a/ReduxMovieDB/Base.lproj/Main.storyboard b/ReduxMovieDB/Base.lproj/Main.storyboard index 1f773e8..07a8960 100644 --- a/ReduxMovieDB/Base.lproj/Main.storyboard +++ b/ReduxMovieDB/Base.lproj/Main.storyboard @@ -2,7 +2,6 @@ - @@ -128,11 +127,7 @@ - - - - - + @@ -201,9 +196,6 @@ - - - diff --git a/ReduxMovieDB/Controllers/MovieDetailViewController.swift b/ReduxMovieDB/Controllers/MovieDetailViewController.swift index 3301515..b4078e2 100644 --- a/ReduxMovieDB/Controllers/MovieDetailViewController.swift +++ b/ReduxMovieDB/Controllers/MovieDetailViewController.swift @@ -22,7 +22,18 @@ class MovieDetailViewController: UITableViewController { @IBOutlet weak var genreValue: UILabel! @IBOutlet weak var overviewLabel: UILabel! @IBOutlet weak var overviewValue: UILabel! - @IBOutlet weak var favoriteButton: UIButton! + @IBOutlet weak var favoriteButton: UIButton! { + didSet { + favoriteButton + .rx + .tap + .subscribe { [weak self] (event) in + guard case .next = event else { return } + mainStore.dispatch(MainStateAction.toggleFavoriteMovie) + self?.favoriteButton.setTitle(mainStore.state.isCurrentFavorite.star, for: .normal) + }.disposed(by: disposeBag) + } + } @IBOutlet weak var posterImageView: UIImageView! @@ -79,10 +90,6 @@ class MovieDetailViewController: UITableViewController { return defaultHeight } - @IBAction func toggleFavorite(_ sender: Any) { - mainStore.dispatch(MainStateAction.toggleFavoriteMovie) - favoriteButton.setTitle(mainStore.state.isCurrentFavorite ? "★" : "☆", for: .normal) - } func setupPosterView() { posterView = tableView.tableHeaderView @@ -112,7 +119,6 @@ class MovieDetailViewController: UITableViewController { genreLabel.text = NSLocalizedString("GENRE", comment: "Film genre") overviewLabel.text = NSLocalizedString("OVERVIEW", comment: "Film overview") } - } // MARK: StoreSubscriber @@ -130,7 +136,7 @@ extension MovieDetailViewController: StoreSubscriber { overviewValue.text = state.overview if let isFavorite = state.isFavorite { - favoriteButton.setTitle(isFavorite ? "★" : "☆", for: .normal) + favoriteButton.setTitle(isFavorite.star, for: .normal) favoriteButton.isEnabled = true } else { favoriteButton.isEnabled = false diff --git a/ReduxMovieDB/Controllers/MovieListViewController.swift b/ReduxMovieDB/Controllers/MovieListViewController.swift index f6017e5..4d65a18 100644 --- a/ReduxMovieDB/Controllers/MovieListViewController.swift +++ b/ReduxMovieDB/Controllers/MovieListViewController.swift @@ -71,7 +71,18 @@ class MovieListViewController: UIViewController { } } - @IBOutlet weak var favoritesToggleItem: UIBarButtonItem! + @IBOutlet weak var favoritesToggleItem: UIBarButtonItem! { + didSet { + favoritesToggleItem + .rx + .tap + .subscribe { [weak self] (event) in + guard case .next = event else { return } + mainStore.dispatch(MainStateAction.toggleShowFavorites) + self?.favoritesToggleItem.title = mainStore.state.isFavoritesList.star + }.disposed(by: disposeBag) + } + } override func viewDidLoad() { super.viewDidLoad() @@ -93,10 +104,6 @@ class MovieListViewController: UIViewController { super.viewWillDisappear(animated) mainStore.unsubscribe(self) } - - @IBAction func onToggleFavorites(_ sender: Any) { - mainStore.dispatch(MainStateAction.toggleShowFavorites) - } } // MARK: StoreSubscriber diff --git a/ReduxMovieDB/Extensions/Boolean.swift b/ReduxMovieDB/Extensions/Boolean.swift new file mode 100644 index 0000000..85ab544 --- /dev/null +++ b/ReduxMovieDB/Extensions/Boolean.swift @@ -0,0 +1,13 @@ +// +// Extension.swift +// ReduxMovieDB +// +// Created by Aleksei Cherepanov on 17.10.2019. +// Copyright © 2019 Matheus Cardoso. All rights reserved. +// + +import Foundation + +extension Bool { + var star: String { return self ? "★" : "☆" } +} diff --git a/ReduxMovieDBTests/State/MainStateTests.swift b/ReduxMovieDBTests/State/MainStateTests.swift index cf394a8..f929b7c 100644 --- a/ReduxMovieDBTests/State/MainStateTests.swift +++ b/ReduxMovieDBTests/State/MainStateTests.swift @@ -14,6 +14,7 @@ import XCTest struct EmptyAction: Action { } class ReduxMovieDBTests: XCTestCase { + typealias MockDefaults = UserDefaultsFavoritesStorageTests.MockDefaults func lint(_ action: MainStateAction) -> () -> Void { switch action { @@ -26,7 +27,7 @@ class ReduxMovieDBTests: XCTestCase { case .search(_): return testSearch case .cancelSearch: return testCancelSearch case .toggleFavoriteMovie: return testToggleFavoriteMovie - case .toggleShowFavorites: return toggleShowFavorites + case .toggleShowFavorites: return testToggleShowFavorites } } @@ -156,7 +157,26 @@ class ReduxMovieDBTests: XCTestCase { } } - func testToggleFavoriteMovie() {} - func toggleShowFavorites() {} + func testToggleFavoriteMovie() { + let movie = Movie(id: 0, title: "title", releaseDate: "date", posterPath: "path", genreIds: [], overview: "") + guard let store = favoritesStore as? UserDefaultsFavoritesStorage else { + return XCTFail("Unexpected favorite store") + } + store.defaults = MockDefaults() + + let action = MainStateAction.showMovieDetail(movie) + let state = mainReducer(action: action, state: nil) + XCTAssertFalse(state.isFavorite(id: 0)) + + let toggleAction = MainStateAction.toggleFavoriteMovie + let resultState = mainReducer(action: toggleAction, state: state) + XCTAssertTrue(resultState.isFavorite(id: 0)) + } + + func testToggleShowFavorites() { + let action = MainStateAction.toggleShowFavorites + let state = mainReducer(action: action, state: nil) + XCTAssertTrue(state.isFavoritesList) + } }