diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..171abf2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +**/xcuserdata +**.xcuserstate +*.DS_Store \ No newline at end of file diff --git a/MyMovies.xcodeproj/project.pbxproj b/MyMovies.xcodeproj/project.pbxproj new file mode 100644 index 00000000..61e72cf2 --- /dev/null +++ b/MyMovies.xcodeproj/project.pbxproj @@ -0,0 +1,963 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 7D0D8686297623AE00BCD50D /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0D8685297623AE00BCD50D /* MockUserDefaults.swift */; }; + 7D0D8688297624D300BCD50D /* FavoriteRepositorySpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0D8687297624D300BCD50D /* FavoriteRepositorySpec.swift */; }; + 7D0D868B2976260200BCD50D /* favorite.json in Resources */ = {isa = PBXBuildFile; fileRef = 7D0D868A2976260200BCD50D /* favorite.json */; }; + 7D35A404296E22430054954E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A403296E22430054954E /* AppDelegate.swift */; }; + 7D35A406296E22430054954E /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A405296E22430054954E /* SceneDelegate.swift */; }; + 7D35A40D296E22450054954E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7D35A40C296E22450054954E /* Assets.xcassets */; }; + 7D35A410296E22450054954E /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7D35A40E296E22450054954E /* LaunchScreen.storyboard */; }; + 7D35A425296E22450054954E /* MyMoviesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A424296E22450054954E /* MyMoviesUITests.swift */; }; + 7D35A427296E22450054954E /* MyMoviesUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A426296E22450054954E /* MyMoviesUITestsLaunchTests.swift */; }; + 7D35A435296E233F0054954E /* MainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A434296E233F0054954E /* MainCoordinator.swift */; }; + 7D35A437296E23740054954E /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A436296E23740054954E /* Coordinator.swift */; }; + 7D35A43A296E27AE0054954E /* APIEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A439296E27AE0054954E /* APIEnvironment.swift */; }; + 7D35A43C296E28490054954E /* APIPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A43B296E28490054954E /* APIPath.swift */; }; + 7D35A43E296E29000054954E /* APIHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A43D296E29000054954E /* APIHandler.swift */; }; + 7D35A440296E29280054954E /* ResponseHandler+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A43F296E29280054954E /* ResponseHandler+Extension.swift */; }; + 7D35A442296E293D0054954E /* ServiceError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A441296E293D0054954E /* ServiceError.swift */; }; + 7D35A444296E29950054954E /* APILoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A443296E29950054954E /* APILoader.swift */; }; + 7D35A447296E2A390054954E /* TrendingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A446296E2A390054954E /* TrendingAPI.swift */; }; + 7D35A44B296E2C920054954E /* TrendingDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A44A296E2C920054954E /* TrendingDTO.swift */; }; + 7D35A44D296E2CA80054954E /* TrendingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A44C296E2CA80054954E /* TrendingResult.swift */; }; + 7D35A450296E31C30054954E /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A44F296E31C30054954E /* HomeViewController.swift */; }; + 7D35A453296E32EE0054954E /* MovieListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A452296E32EE0054954E /* MovieListViewController.swift */; }; + 7D35A45B296E36DD0054954E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A45A296E36DD0054954E /* HomeView.swift */; }; + 7D35A45F296E3D360054954E /* MovieListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A45E296E3D360054954E /* MovieListView.swift */; }; + 7D35A461296E3D5F0054954E /* MovieListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A460296E3D5F0054954E /* MovieListCollectionViewCell.swift */; }; + 7D35A463296E3E410054954E /* TrendingListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A462296E3E410054954E /* TrendingListViewModel.swift */; }; + 7D35A465296E45170054954E /* TrendingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A464296E45170054954E /* TrendingViewModel.swift */; }; + 7D35A467296E4C170054954E /* UIImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A466296E4C170054954E /* UIImageLoader.swift */; }; + 7D35A46A296E4C820054954E /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A469296E4C820054954E /* ImageLoader.swift */; }; + 7D35A46C296E4D090054954E /* UIImageView+ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A46B296E4D090054954E /* UIImageView+ImageLoader.swift */; }; + 7D35A46E296F98C00054954E /* TopRatedListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A46D296F98C00054954E /* TopRatedListViewModel.swift */; }; + 7D35A470296F99430054954E /* TopRatedAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A46F296F99430054954E /* TopRatedAPI.swift */; }; + 7D35A476297207C20054954E /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A475297207C20054954E /* Movie.swift */; }; + 7D35A478297209250054954E /* MovieViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A477297209250054954E /* MovieViewModel.swift */; }; + 7D35A47A29720A810054954E /* MovieAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A47929720A810054954E /* MovieAPI.swift */; }; + 7D35A47C29720CD40054954E /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A47B29720CD40054954E /* Event.swift */; }; + 7D35A47F29720DB50054954E /* MovieViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A47E29720DB50054954E /* MovieViewController.swift */; }; + 7D35A48129720F0E0054954E /* MovieView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A48029720F0E0054954E /* MovieView.swift */; }; + 7D35A4872972D6D40054954E /* MockURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A4862972D6D40054954E /* MockURLProtocol.swift */; }; + 7D35A4892972D70E0054954E /* TrendingListViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D35A4882972D70E0054954E /* TrendingListViewModelSpec.swift */; }; + 7DBF51A429761D7D0084587B /* JsonHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DBF51A329761D7D0084587B /* JsonHelper.swift */; }; + 7DF3DD092972DC32007BF9A3 /* trending-error.json in Resources */ = {isa = PBXBuildFile; fileRef = 7DF3DD082972DC32007BF9A3 /* trending-error.json */; }; + 7DF3DD0B2972DC3B007BF9A3 /* trending-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 7DF3DD0A2972DC3B007BF9A3 /* trending-success.json */; }; + 7DF3DD0D2972DE25007BF9A3 /* TopRatedListViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF3DD0C2972DE25007BF9A3 /* TopRatedListViewModelSpec.swift */; }; + 7DF3DD102972DEED007BF9A3 /* top-rated-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 7DF3DD0F2972DEED007BF9A3 /* top-rated-success.json */; }; + 7DF3DD122972DF22007BF9A3 /* top-rated-error.json in Resources */ = {isa = PBXBuildFile; fileRef = 7DF3DD112972DF22007BF9A3 /* top-rated-error.json */; }; + 7DF3DD142972DF75007BF9A3 /* MovieViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF3DD132972DF75007BF9A3 /* MovieViewModelSpec.swift */; }; + 7DF3DD182972E12C007BF9A3 /* movie-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 7DF3DD162972E12C007BF9A3 /* movie-success.json */; }; + 7DF3DD192972E12C007BF9A3 /* movie-error.json in Resources */ = {isa = PBXBuildFile; fileRef = 7DF3DD172972E12C007BF9A3 /* movie-error.json */; }; + 7DF3DD1B2972E23B007BF9A3 /* TrendingViewModelSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF3DD1A2972E23B007BF9A3 /* TrendingViewModelSpec.swift */; }; + 7DF3DD1E2972E3A7007BF9A3 /* trending-result.json in Resources */ = {isa = PBXBuildFile; fileRef = 7DF3DD1D2972E3A7007BF9A3 /* trending-result.json */; }; + 7DF3DD202972E641007BF9A3 /* trending-result-nullable.json in Resources */ = {isa = PBXBuildFile; fileRef = 7DF3DD1F2972E641007BF9A3 /* trending-result-nullable.json */; }; + 7DF3DD232972E725007BF9A3 /* MainCoordinatorSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF3DD222972E725007BF9A3 /* MainCoordinatorSpec.swift */; }; + 7DF3DD252972E7AC007BF9A3 /* MockUINavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF3DD242972E7AC007BF9A3 /* MockUINavigationController.swift */; }; + 7DF3DD282972F5A4007BF9A3 /* FavoriteRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF3DD272972F5A4007BF9A3 /* FavoriteRepository.swift */; }; + 7DF3DD2B29730F28007BF9A3 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF3DD2A29730F28007BF9A3 /* FavoriteViewModel.swift */; }; + 7DF3DD2E29731AAD007BF9A3 /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DF3DD2D29731AAD007BF9A3 /* Button.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 7D35A417296E22450054954E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7D35A3F8296E22430054954E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7D35A3FF296E22430054954E; + remoteInfo = MyMovies; + }; + 7D35A421296E22450054954E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7D35A3F8296E22430054954E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7D35A3FF296E22430054954E; + remoteInfo = MyMovies; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 7D0D8685297623AE00BCD50D /* MockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUserDefaults.swift; sourceTree = ""; }; + 7D0D8687297624D300BCD50D /* FavoriteRepositorySpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteRepositorySpec.swift; sourceTree = ""; }; + 7D0D868A2976260200BCD50D /* favorite.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = favorite.json; sourceTree = ""; }; + 7D35A400296E22430054954E /* MyMovies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MyMovies.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7D35A403296E22430054954E /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7D35A405296E22430054954E /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7D35A40C296E22450054954E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 7D35A40F296E22450054954E /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 7D35A411296E22450054954E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 7D35A416296E22450054954E /* MyMoviesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MyMoviesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7D35A420296E22450054954E /* MyMoviesUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MyMoviesUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7D35A424296E22450054954E /* MyMoviesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyMoviesUITests.swift; sourceTree = ""; }; + 7D35A426296E22450054954E /* MyMoviesUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyMoviesUITestsLaunchTests.swift; sourceTree = ""; }; + 7D35A434296E233F0054954E /* MainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinator.swift; sourceTree = ""; }; + 7D35A436296E23740054954E /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; + 7D35A439296E27AE0054954E /* APIEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIEnvironment.swift; sourceTree = ""; }; + 7D35A43B296E28490054954E /* APIPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIPath.swift; sourceTree = ""; }; + 7D35A43D296E29000054954E /* APIHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIHandler.swift; sourceTree = ""; }; + 7D35A43F296E29280054954E /* ResponseHandler+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ResponseHandler+Extension.swift"; sourceTree = ""; }; + 7D35A441296E293D0054954E /* ServiceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceError.swift; sourceTree = ""; }; + 7D35A443296E29950054954E /* APILoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APILoader.swift; sourceTree = ""; }; + 7D35A446296E2A390054954E /* TrendingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingAPI.swift; sourceTree = ""; }; + 7D35A44A296E2C920054954E /* TrendingDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingDTO.swift; sourceTree = ""; }; + 7D35A44C296E2CA80054954E /* TrendingResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingResult.swift; sourceTree = ""; }; + 7D35A44F296E31C30054954E /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = ""; }; + 7D35A452296E32EE0054954E /* MovieListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListViewController.swift; sourceTree = ""; }; + 7D35A45A296E36DD0054954E /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 7D35A45E296E3D360054954E /* MovieListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListView.swift; sourceTree = ""; }; + 7D35A460296E3D5F0054954E /* MovieListCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListCollectionViewCell.swift; sourceTree = ""; }; + 7D35A462296E3E410054954E /* TrendingListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingListViewModel.swift; sourceTree = ""; }; + 7D35A464296E45170054954E /* TrendingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingViewModel.swift; sourceTree = ""; }; + 7D35A466296E4C170054954E /* UIImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImageLoader.swift; sourceTree = ""; }; + 7D35A469296E4C820054954E /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; + 7D35A46B296E4D090054954E /* UIImageView+ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImageView+ImageLoader.swift"; sourceTree = ""; }; + 7D35A46D296F98C00054954E /* TopRatedListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopRatedListViewModel.swift; sourceTree = ""; }; + 7D35A46F296F99430054954E /* TopRatedAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopRatedAPI.swift; sourceTree = ""; }; + 7D35A475297207C20054954E /* Movie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Movie.swift; sourceTree = ""; }; + 7D35A477297209250054954E /* MovieViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieViewModel.swift; sourceTree = ""; }; + 7D35A47929720A810054954E /* MovieAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieAPI.swift; sourceTree = ""; }; + 7D35A47B29720CD40054954E /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = ""; }; + 7D35A47E29720DB50054954E /* MovieViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieViewController.swift; sourceTree = ""; }; + 7D35A48029720F0E0054954E /* MovieView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieView.swift; sourceTree = ""; }; + 7D35A4862972D6D40054954E /* MockURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLProtocol.swift; sourceTree = ""; }; + 7D35A4882972D70E0054954E /* TrendingListViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingListViewModelSpec.swift; sourceTree = ""; }; + 7DBF51A329761D7D0084587B /* JsonHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonHelper.swift; sourceTree = ""; }; + 7DF3DD082972DC32007BF9A3 /* trending-error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "trending-error.json"; sourceTree = ""; }; + 7DF3DD0A2972DC3B007BF9A3 /* trending-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "trending-success.json"; sourceTree = ""; }; + 7DF3DD0C2972DE25007BF9A3 /* TopRatedListViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopRatedListViewModelSpec.swift; sourceTree = ""; }; + 7DF3DD0F2972DEED007BF9A3 /* top-rated-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "top-rated-success.json"; sourceTree = ""; }; + 7DF3DD112972DF22007BF9A3 /* top-rated-error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "top-rated-error.json"; sourceTree = ""; }; + 7DF3DD132972DF75007BF9A3 /* MovieViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieViewModelSpec.swift; sourceTree = ""; }; + 7DF3DD162972E12C007BF9A3 /* movie-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "movie-success.json"; sourceTree = ""; }; + 7DF3DD172972E12C007BF9A3 /* movie-error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "movie-error.json"; sourceTree = ""; }; + 7DF3DD1A2972E23B007BF9A3 /* TrendingViewModelSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingViewModelSpec.swift; sourceTree = ""; }; + 7DF3DD1D2972E3A7007BF9A3 /* trending-result.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "trending-result.json"; sourceTree = ""; }; + 7DF3DD1F2972E641007BF9A3 /* trending-result-nullable.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "trending-result-nullable.json"; sourceTree = ""; }; + 7DF3DD222972E725007BF9A3 /* MainCoordinatorSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinatorSpec.swift; sourceTree = ""; }; + 7DF3DD242972E7AC007BF9A3 /* MockUINavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockUINavigationController.swift; sourceTree = ""; }; + 7DF3DD272972F5A4007BF9A3 /* FavoriteRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteRepository.swift; sourceTree = ""; }; + 7DF3DD2A29730F28007BF9A3 /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = ""; }; + 7DF3DD2D29731AAD007BF9A3 /* Button.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Button.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7D35A3FD296E22430054954E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7D35A413296E22450054954E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7D35A41D296E22450054954E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7D0D8689297625C600BCD50D /* Favorite */ = { + isa = PBXGroup; + children = ( + 7D0D868A2976260200BCD50D /* favorite.json */, + ); + path = Favorite; + sourceTree = ""; + }; + 7D35A3F7296E22430054954E = { + isa = PBXGroup; + children = ( + 7D35A402296E22430054954E /* MyMovies */, + 7D35A419296E22450054954E /* MyMoviesTests */, + 7D35A423296E22450054954E /* MyMoviesUITests */, + 7D35A401296E22430054954E /* Products */, + ); + sourceTree = ""; + }; + 7D35A401296E22430054954E /* Products */ = { + isa = PBXGroup; + children = ( + 7D35A400296E22430054954E /* MyMovies.app */, + 7D35A416296E22450054954E /* MyMoviesTests.xctest */, + 7D35A420296E22450054954E /* MyMoviesUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 7D35A402296E22430054954E /* MyMovies */ = { + isa = PBXGroup; + children = ( + 7D35A448296E2BDB0054954E /* Model */, + 7D35A44E296E318E0054954E /* View */, + 7D35A445296E2A250054954E /* Service */, + 7D35A438296E27930054954E /* Network */, + 7D35A433296E232E0054954E /* Coordinator */, + 7D35A403296E22430054954E /* AppDelegate.swift */, + 7D35A405296E22430054954E /* SceneDelegate.swift */, + 7D35A40C296E22450054954E /* Assets.xcassets */, + 7D35A40E296E22450054954E /* LaunchScreen.storyboard */, + 7D35A411296E22450054954E /* Info.plist */, + ); + path = MyMovies; + sourceTree = ""; + }; + 7D35A419296E22450054954E /* MyMoviesTests */ = { + isa = PBXGroup; + children = ( + 7DBF51A229761D710084587B /* Helper */, + 7DF3DD212972E6EE007BF9A3 /* Coordinator */, + 7DF3DD012972DB63007BF9A3 /* Model */, + 7D35A4822972D5B00054954E /* Mocks */, + ); + path = MyMoviesTests; + sourceTree = ""; + }; + 7D35A423296E22450054954E /* MyMoviesUITests */ = { + isa = PBXGroup; + children = ( + 7D35A424296E22450054954E /* MyMoviesUITests.swift */, + 7D35A426296E22450054954E /* MyMoviesUITestsLaunchTests.swift */, + ); + path = MyMoviesUITests; + sourceTree = ""; + }; + 7D35A433296E232E0054954E /* Coordinator */ = { + isa = PBXGroup; + children = ( + 7D35A434296E233F0054954E /* MainCoordinator.swift */, + 7D35A436296E23740054954E /* Coordinator.swift */, + 7D35A47B29720CD40054954E /* Event.swift */, + ); + path = Coordinator; + sourceTree = ""; + }; + 7D35A438296E27930054954E /* Network */ = { + isa = PBXGroup; + children = ( + 7D35A439296E27AE0054954E /* APIEnvironment.swift */, + 7D35A43B296E28490054954E /* APIPath.swift */, + 7D35A43D296E29000054954E /* APIHandler.swift */, + 7D35A43F296E29280054954E /* ResponseHandler+Extension.swift */, + 7D35A441296E293D0054954E /* ServiceError.swift */, + 7D35A443296E29950054954E /* APILoader.swift */, + ); + path = Network; + sourceTree = ""; + }; + 7D35A445296E2A250054954E /* Service */ = { + isa = PBXGroup; + children = ( + 7D35A468296E4C1E0054954E /* ImageLoader */, + 7D35A446296E2A390054954E /* TrendingAPI.swift */, + 7D35A46F296F99430054954E /* TopRatedAPI.swift */, + 7D35A47929720A810054954E /* MovieAPI.swift */, + ); + path = Service; + sourceTree = ""; + }; + 7D35A448296E2BDB0054954E /* Model */ = { + isa = PBXGroup; + children = ( + 7DF3DD262972F596007BF9A3 /* Favorite */, + 7D35A449296E2C870054954E /* DTO */, + 7D35A462296E3E410054954E /* TrendingListViewModel.swift */, + 7D35A464296E45170054954E /* TrendingViewModel.swift */, + 7D35A46D296F98C00054954E /* TopRatedListViewModel.swift */, + 7D35A477297209250054954E /* MovieViewModel.swift */, + ); + path = Model; + sourceTree = ""; + }; + 7D35A449296E2C870054954E /* DTO */ = { + isa = PBXGroup; + children = ( + 7D35A44C296E2CA80054954E /* TrendingResult.swift */, + 7D35A44A296E2C920054954E /* TrendingDTO.swift */, + 7D35A475297207C20054954E /* Movie.swift */, + ); + path = DTO; + sourceTree = ""; + }; + 7D35A44E296E318E0054954E /* View */ = { + isa = PBXGroup; + children = ( + 7DF3DD2C29731AA3007BF9A3 /* Button */, + 7D35A459296E36C60054954E /* Home */, + 7D35A47D29720D900054954E /* Movie */, + 7D35A451296E32DC0054954E /* Trending */, + ); + path = View; + sourceTree = ""; + }; + 7D35A451296E32DC0054954E /* Trending */ = { + isa = PBXGroup; + children = ( + 7D35A452296E32EE0054954E /* MovieListViewController.swift */, + 7D35A45E296E3D360054954E /* MovieListView.swift */, + 7D35A460296E3D5F0054954E /* MovieListCollectionViewCell.swift */, + ); + path = Trending; + sourceTree = ""; + }; + 7D35A459296E36C60054954E /* Home */ = { + isa = PBXGroup; + children = ( + 7D35A44F296E31C30054954E /* HomeViewController.swift */, + 7D35A45A296E36DD0054954E /* HomeView.swift */, + ); + path = Home; + sourceTree = ""; + }; + 7D35A468296E4C1E0054954E /* ImageLoader */ = { + isa = PBXGroup; + children = ( + 7D35A466296E4C170054954E /* UIImageLoader.swift */, + 7D35A469296E4C820054954E /* ImageLoader.swift */, + 7D35A46B296E4D090054954E /* UIImageView+ImageLoader.swift */, + ); + path = ImageLoader; + sourceTree = ""; + }; + 7D35A47D29720D900054954E /* Movie */ = { + isa = PBXGroup; + children = ( + 7D35A47E29720DB50054954E /* MovieViewController.swift */, + 7D35A48029720F0E0054954E /* MovieView.swift */, + ); + path = Movie; + sourceTree = ""; + }; + 7D35A4822972D5B00054954E /* Mocks */ = { + isa = PBXGroup; + children = ( + 7D0D8689297625C600BCD50D /* Favorite */, + 7DF3DD1C2972E36E007BF9A3 /* TrendingResult */, + 7DF3DD152972E0F6007BF9A3 /* Movie */, + 7DF3DD0E2972DEDE007BF9A3 /* TopRated */, + 7D35A4832972D5BE0054954E /* Trending */, + 7D35A4862972D6D40054954E /* MockURLProtocol.swift */, + 7DF3DD242972E7AC007BF9A3 /* MockUINavigationController.swift */, + 7D0D8685297623AE00BCD50D /* MockUserDefaults.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + 7D35A4832972D5BE0054954E /* Trending */ = { + isa = PBXGroup; + children = ( + 7DF3DD082972DC32007BF9A3 /* trending-error.json */, + 7DF3DD0A2972DC3B007BF9A3 /* trending-success.json */, + ); + path = Trending; + sourceTree = ""; + }; + 7DBF51A229761D710084587B /* Helper */ = { + isa = PBXGroup; + children = ( + 7DBF51A329761D7D0084587B /* JsonHelper.swift */, + ); + path = Helper; + sourceTree = ""; + }; + 7DF3DD012972DB63007BF9A3 /* Model */ = { + isa = PBXGroup; + children = ( + 7D35A4882972D70E0054954E /* TrendingListViewModelSpec.swift */, + 7DF3DD0C2972DE25007BF9A3 /* TopRatedListViewModelSpec.swift */, + 7DF3DD132972DF75007BF9A3 /* MovieViewModelSpec.swift */, + 7DF3DD1A2972E23B007BF9A3 /* TrendingViewModelSpec.swift */, + 7D0D8687297624D300BCD50D /* FavoriteRepositorySpec.swift */, + ); + path = Model; + sourceTree = ""; + }; + 7DF3DD0E2972DEDE007BF9A3 /* TopRated */ = { + isa = PBXGroup; + children = ( + 7DF3DD0F2972DEED007BF9A3 /* top-rated-success.json */, + 7DF3DD112972DF22007BF9A3 /* top-rated-error.json */, + ); + path = TopRated; + sourceTree = ""; + }; + 7DF3DD152972E0F6007BF9A3 /* Movie */ = { + isa = PBXGroup; + children = ( + 7DF3DD172972E12C007BF9A3 /* movie-error.json */, + 7DF3DD162972E12C007BF9A3 /* movie-success.json */, + ); + path = Movie; + sourceTree = ""; + }; + 7DF3DD1C2972E36E007BF9A3 /* TrendingResult */ = { + isa = PBXGroup; + children = ( + 7DF3DD1D2972E3A7007BF9A3 /* trending-result.json */, + 7DF3DD1F2972E641007BF9A3 /* trending-result-nullable.json */, + ); + path = TrendingResult; + sourceTree = ""; + }; + 7DF3DD212972E6EE007BF9A3 /* Coordinator */ = { + isa = PBXGroup; + children = ( + 7DF3DD222972E725007BF9A3 /* MainCoordinatorSpec.swift */, + ); + path = Coordinator; + sourceTree = ""; + }; + 7DF3DD262972F596007BF9A3 /* Favorite */ = { + isa = PBXGroup; + children = ( + 7DF3DD272972F5A4007BF9A3 /* FavoriteRepository.swift */, + 7DF3DD2A29730F28007BF9A3 /* FavoriteViewModel.swift */, + ); + path = Favorite; + sourceTree = ""; + }; + 7DF3DD2C29731AA3007BF9A3 /* Button */ = { + isa = PBXGroup; + children = ( + 7DF3DD2D29731AAD007BF9A3 /* Button.swift */, + ); + path = Button; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7D35A3FF296E22430054954E /* MyMovies */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7D35A42A296E22450054954E /* Build configuration list for PBXNativeTarget "MyMovies" */; + buildPhases = ( + 7D35A3FC296E22430054954E /* Sources */, + 7D35A3FD296E22430054954E /* Frameworks */, + 7D35A3FE296E22430054954E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MyMovies; + productName = MyMovies; + productReference = 7D35A400296E22430054954E /* MyMovies.app */; + productType = "com.apple.product-type.application"; + }; + 7D35A415296E22450054954E /* MyMoviesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7D35A42D296E22450054954E /* Build configuration list for PBXNativeTarget "MyMoviesTests" */; + buildPhases = ( + 7D35A412296E22450054954E /* Sources */, + 7D35A413296E22450054954E /* Frameworks */, + 7D35A414296E22450054954E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7D35A418296E22450054954E /* PBXTargetDependency */, + ); + name = MyMoviesTests; + productName = MyMoviesTests; + productReference = 7D35A416296E22450054954E /* MyMoviesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 7D35A41F296E22450054954E /* MyMoviesUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7D35A430296E22450054954E /* Build configuration list for PBXNativeTarget "MyMoviesUITests" */; + buildPhases = ( + 7D35A41C296E22450054954E /* Sources */, + 7D35A41D296E22450054954E /* Frameworks */, + 7D35A41E296E22450054954E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7D35A422296E22450054954E /* PBXTargetDependency */, + ); + name = MyMoviesUITests; + productName = MyMoviesUITests; + productReference = 7D35A420296E22450054954E /* MyMoviesUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7D35A3F8296E22430054954E /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1420; + TargetAttributes = { + 7D35A3FF296E22430054954E = { + CreatedOnToolsVersion = 14.2; + }; + 7D35A415296E22450054954E = { + CreatedOnToolsVersion = 14.2; + TestTargetID = 7D35A3FF296E22430054954E; + }; + 7D35A41F296E22450054954E = { + CreatedOnToolsVersion = 14.2; + TestTargetID = 7D35A3FF296E22430054954E; + }; + }; + }; + buildConfigurationList = 7D35A3FB296E22430054954E /* Build configuration list for PBXProject "MyMovies" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7D35A3F7296E22430054954E; + productRefGroup = 7D35A401296E22430054954E /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7D35A3FF296E22430054954E /* MyMovies */, + 7D35A415296E22450054954E /* MyMoviesTests */, + 7D35A41F296E22450054954E /* MyMoviesUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7D35A3FE296E22430054954E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7D35A410296E22450054954E /* LaunchScreen.storyboard in Resources */, + 7D35A40D296E22450054954E /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7D35A414296E22450054954E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7DF3DD202972E641007BF9A3 /* trending-result-nullable.json in Resources */, + 7D0D868B2976260200BCD50D /* favorite.json in Resources */, + 7DF3DD0B2972DC3B007BF9A3 /* trending-success.json in Resources */, + 7DF3DD102972DEED007BF9A3 /* top-rated-success.json in Resources */, + 7DF3DD1E2972E3A7007BF9A3 /* trending-result.json in Resources */, + 7DF3DD182972E12C007BF9A3 /* movie-success.json in Resources */, + 7DF3DD192972E12C007BF9A3 /* movie-error.json in Resources */, + 7DF3DD092972DC32007BF9A3 /* trending-error.json in Resources */, + 7DF3DD122972DF22007BF9A3 /* top-rated-error.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7D35A41E296E22450054954E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7D35A3FC296E22430054954E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7D35A442296E293D0054954E /* ServiceError.swift in Sources */, + 7D35A453296E32EE0054954E /* MovieListViewController.swift in Sources */, + 7D35A447296E2A390054954E /* TrendingAPI.swift in Sources */, + 7D35A435296E233F0054954E /* MainCoordinator.swift in Sources */, + 7D35A44B296E2C920054954E /* TrendingDTO.swift in Sources */, + 7D35A450296E31C30054954E /* HomeViewController.swift in Sources */, + 7D35A43A296E27AE0054954E /* APIEnvironment.swift in Sources */, + 7D35A461296E3D5F0054954E /* MovieListCollectionViewCell.swift in Sources */, + 7D35A476297207C20054954E /* Movie.swift in Sources */, + 7D35A45F296E3D360054954E /* MovieListView.swift in Sources */, + 7D35A48129720F0E0054954E /* MovieView.swift in Sources */, + 7D35A43E296E29000054954E /* APIHandler.swift in Sources */, + 7D35A47F29720DB50054954E /* MovieViewController.swift in Sources */, + 7D35A465296E45170054954E /* TrendingViewModel.swift in Sources */, + 7D35A45B296E36DD0054954E /* HomeView.swift in Sources */, + 7D35A437296E23740054954E /* Coordinator.swift in Sources */, + 7D35A478297209250054954E /* MovieViewModel.swift in Sources */, + 7D35A47A29720A810054954E /* MovieAPI.swift in Sources */, + 7D35A463296E3E410054954E /* TrendingListViewModel.swift in Sources */, + 7DF3DD282972F5A4007BF9A3 /* FavoriteRepository.swift in Sources */, + 7D35A46A296E4C820054954E /* ImageLoader.swift in Sources */, + 7DF3DD2B29730F28007BF9A3 /* FavoriteViewModel.swift in Sources */, + 7D35A43C296E28490054954E /* APIPath.swift in Sources */, + 7D35A47C29720CD40054954E /* Event.swift in Sources */, + 7D35A46C296E4D090054954E /* UIImageView+ImageLoader.swift in Sources */, + 7D35A467296E4C170054954E /* UIImageLoader.swift in Sources */, + 7D35A44D296E2CA80054954E /* TrendingResult.swift in Sources */, + 7D35A444296E29950054954E /* APILoader.swift in Sources */, + 7D35A440296E29280054954E /* ResponseHandler+Extension.swift in Sources */, + 7D35A404296E22430054954E /* AppDelegate.swift in Sources */, + 7D35A406296E22430054954E /* SceneDelegate.swift in Sources */, + 7D35A46E296F98C00054954E /* TopRatedListViewModel.swift in Sources */, + 7D35A470296F99430054954E /* TopRatedAPI.swift in Sources */, + 7DF3DD2E29731AAD007BF9A3 /* Button.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7D35A412296E22450054954E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7DBF51A429761D7D0084587B /* JsonHelper.swift in Sources */, + 7D35A4892972D70E0054954E /* TrendingListViewModelSpec.swift in Sources */, + 7DF3DD0D2972DE25007BF9A3 /* TopRatedListViewModelSpec.swift in Sources */, + 7DF3DD1B2972E23B007BF9A3 /* TrendingViewModelSpec.swift in Sources */, + 7DF3DD142972DF75007BF9A3 /* MovieViewModelSpec.swift in Sources */, + 7D0D8686297623AE00BCD50D /* MockUserDefaults.swift in Sources */, + 7D0D8688297624D300BCD50D /* FavoriteRepositorySpec.swift in Sources */, + 7DF3DD232972E725007BF9A3 /* MainCoordinatorSpec.swift in Sources */, + 7D35A4872972D6D40054954E /* MockURLProtocol.swift in Sources */, + 7DF3DD252972E7AC007BF9A3 /* MockUINavigationController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7D35A41C296E22450054954E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7D35A427296E22450054954E /* MyMoviesUITestsLaunchTests.swift in Sources */, + 7D35A425296E22450054954E /* MyMoviesUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 7D35A418296E22450054954E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7D35A3FF296E22430054954E /* MyMovies */; + targetProxy = 7D35A417296E22450054954E /* PBXContainerItemProxy */; + }; + 7D35A422296E22450054954E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7D35A3FF296E22430054954E /* MyMovies */; + targetProxy = 7D35A421296E22450054954E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 7D35A40E296E22450054954E /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 7D35A40F296E22450054954E /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 7D35A428296E22450054954E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7D35A429296E22450054954E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7D35A42B296E22450054954E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + BASE_API_KEY = 16094d8ca19f9c0407db3d0b5203bd21; + BASE_IMAGE_URL = "https://image.tmdb.org/t/p"; + BASE_URL = "https://api.themoviedb.org"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T2QW6534L6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MyMovies/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.daresay.caioambrosio.MyMovies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7D35A42C296E22450054954E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + BASE_API_KEY = 16094d8ca19f9c0407db3d0b5203bd21; + BASE_IMAGE_URL = "https://image.tmdb.org/t/p"; + BASE_URL = "https://api.themoviedb.org"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T2QW6534L6; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = MyMovies/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.daresay.caioambrosio.MyMovies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 7D35A42E296E22450054954E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T2QW6534L6; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.daresay.caioambrosio.MyMoviesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MyMovies.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MyMovies"; + }; + name = Debug; + }; + 7D35A42F296E22450054954E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T2QW6534L6; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.daresay.caioambrosio.MyMoviesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MyMovies.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MyMovies"; + }; + name = Release; + }; + 7D35A431296E22450054954E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T2QW6534L6; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.daresay.caioambrosio.MyMoviesUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = MyMovies; + }; + name = Debug; + }; + 7D35A432296E22450054954E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T2QW6534L6; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.daresay.caioambrosio.MyMoviesUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = MyMovies; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7D35A3FB296E22430054954E /* Build configuration list for PBXProject "MyMovies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7D35A428296E22450054954E /* Debug */, + 7D35A429296E22450054954E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7D35A42A296E22450054954E /* Build configuration list for PBXNativeTarget "MyMovies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7D35A42B296E22450054954E /* Debug */, + 7D35A42C296E22450054954E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7D35A42D296E22450054954E /* Build configuration list for PBXNativeTarget "MyMoviesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7D35A42E296E22450054954E /* Debug */, + 7D35A42F296E22450054954E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7D35A430296E22450054954E /* Build configuration list for PBXNativeTarget "MyMoviesUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7D35A431296E22450054954E /* Debug */, + 7D35A432296E22450054954E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7D35A3F8296E22430054954E /* Project object */; +} diff --git a/MyMovies.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/MyMovies.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/MyMovies.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/MyMovies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/MyMovies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/MyMovies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/MyMovies/AppDelegate.swift b/MyMovies/AppDelegate.swift new file mode 100644 index 00000000..f98fdcac --- /dev/null +++ b/MyMovies/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/MyMovies/Assets.xcassets/AccentColor.colorset/Contents.json b/MyMovies/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/MyMovies/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MyMovies/Assets.xcassets/AppIcon.appiconset/Contents.json b/MyMovies/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/MyMovies/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MyMovies/Assets.xcassets/Contents.json b/MyMovies/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/MyMovies/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MyMovies/Base.lproj/LaunchScreen.storyboard b/MyMovies/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/MyMovies/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MyMovies/Coordinator/Coordinator.swift b/MyMovies/Coordinator/Coordinator.swift new file mode 100644 index 00000000..3889f03a --- /dev/null +++ b/MyMovies/Coordinator/Coordinator.swift @@ -0,0 +1,19 @@ +// +// Coordinator.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +protocol Coordinator { + var navigationController: UINavigationController { get } + + func eventOccurred(with type: Event, parameters params: Any?) + func start() +} + +protocol Coordinating { + var coordinator: Coordinator? { get set } +} diff --git a/MyMovies/Coordinator/Event.swift b/MyMovies/Coordinator/Event.swift new file mode 100644 index 00000000..8fc0049d --- /dev/null +++ b/MyMovies/Coordinator/Event.swift @@ -0,0 +1,12 @@ +// +// Event.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/13/23. +// + +import Foundation + +enum Event { + case movieClicked, favoriteClicked +} diff --git a/MyMovies/Coordinator/MainCoordinator.swift b/MyMovies/Coordinator/MainCoordinator.swift new file mode 100644 index 00000000..7176206b --- /dev/null +++ b/MyMovies/Coordinator/MainCoordinator.swift @@ -0,0 +1,64 @@ +// +// MainCoordinator.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +class MainCoordinator: Coordinator { + + let navigationController: UINavigationController + + init(navigationController: UINavigationController) { + self.navigationController = navigationController + } + + func eventOccurred(with type: Event, parameters params: Any?) { + switch type { + case .movieClicked: + guard let movieId = params as? Int64 else { return } + + let api = MovieAPI() + let apiLoader = APILoader(apiHandler: api) + let viewModel = MovieViewModel(apiLoader: apiLoader, movieId: movieId) + let viewController = MovieViewController(movieViewModel: viewModel) + + navigationController.pushViewController(viewController, animated: true) + + case .favoriteClicked: + let repository = FavoriteRepository() + let viewModel = FavoriteViewModel(favoriteRepository: repository) + let viewController = MovieListViewController(favoriteViewModel: viewModel, coordinator: self) + + navigationController.pushViewController(viewController, animated: true) + } + } + + func start() { + let apiTrending = TrendingAPI() + let apiLoaderTrending = APILoader(apiHandler: apiTrending) + let trendingViewModel = TrendingListViewModel(apiLoader: apiLoaderTrending) + let trendingViewController = MovieListViewController( + trendingListViewModel: trendingViewModel, + coordinator: self + ) + + let apiTopRated = TopRatedAPI() + let apiLoaderTopRated = APILoader(apiHandler: apiTopRated) + let topRatedListViewModel = TopRatedListViewModel(apiLoader: apiLoaderTopRated) + let topRatedViewController = MovieListViewController( + topRatedListViewModel: topRatedListViewModel, + coordinator: self + ) + + let viewController = HomeViewController( + trendingViewController: trendingViewController, + topRatedViewController: topRatedViewController, + coordinator: self + ) + + navigationController.setViewControllers([viewController], animated: false) + } +} diff --git a/MyMovies/Info.plist b/MyMovies/Info.plist new file mode 100644 index 00000000..6dc4eac8 --- /dev/null +++ b/MyMovies/Info.plist @@ -0,0 +1,29 @@ + + + + + BASE_API_KEY + $(BASE_API_KEY) + BASE_IMAGE_URL + $(BASE_IMAGE_URL) + BASE_URL + $(BASE_URL) + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/MyMovies/Model/DTO/Movie.swift b/MyMovies/Model/DTO/Movie.swift new file mode 100644 index 00000000..8ac384c2 --- /dev/null +++ b/MyMovies/Model/DTO/Movie.swift @@ -0,0 +1,23 @@ +// +// Movie.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/13/23. +// + +import Foundation + +struct Movie: Decodable { + let genres: [Genre] + let originalTitle: String? + let overview: String? + let posterPath: String? + let releaseDate: String? + let title: String? + let voteAverage: Double + let voteCount: Int64 +} + +struct Genre: Decodable { + let name: String +} diff --git a/MyMovies/Model/DTO/TrendingDTO.swift b/MyMovies/Model/DTO/TrendingDTO.swift new file mode 100644 index 00000000..7575ceba --- /dev/null +++ b/MyMovies/Model/DTO/TrendingDTO.swift @@ -0,0 +1,15 @@ +// +// TrendingDTO.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +struct TrendingDTO: Decodable { + let page: Int? + let results: [TrendingResult]? + let totalPages: Int? + let totalResults: Int? +} diff --git a/MyMovies/Model/DTO/TrendingResult.swift b/MyMovies/Model/DTO/TrendingResult.swift new file mode 100644 index 00000000..8b2471e7 --- /dev/null +++ b/MyMovies/Model/DTO/TrendingResult.swift @@ -0,0 +1,15 @@ +// +// TrendingResult.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +struct TrendingResult: Codable { + let posterPath: String? + let id: Int64? + let originalTitle: String? + let name: String? +} diff --git a/MyMovies/Model/Favorite/FavoriteRepository.swift b/MyMovies/Model/Favorite/FavoriteRepository.swift new file mode 100644 index 00000000..079d5f97 --- /dev/null +++ b/MyMovies/Model/Favorite/FavoriteRepository.swift @@ -0,0 +1,102 @@ +// +// FavoriteRepository.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import Foundation + +class FavoriteRepository { + enum Key: String { + case keys + } + + let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func insetOrUpdateMovie(for movieId: Int64, _ trending: TrendingViewModel) { + let encoded = encode(trending) + let movieIdString = String(movieId) + + var keys: [String]? = readValue(for: Key.keys.rawValue) + if keys == nil { + keys = [movieIdString] + } else { + if !keys!.contains(movieIdString) { + keys?.append(movieIdString) + } + } + + saveValue(for: movieIdString, value: encoded) + saveValue(for: Key.keys.rawValue, value: keys!) + } + + func deleteMovie(for movieId: Int64) { + let movieIdString = String(movieId) + + let keys: [String]? = readValue(for: Key.keys.rawValue) + if var keys { + keys.removeAll(where: { $0 == movieIdString }) + if keys.count > 0 { + saveValue(for: Key.keys.rawValue, value: keys) + } else { + removeValue(for: Key.keys.rawValue) + } + } + + removeValue(for: movieIdString) + } + + func getAllFavorites() -> [TrendingViewModel] { + let favorites: [String]? = readValue(for: Key.keys.rawValue) + + guard let favorites else { + return [] + } + + return favorites.compactMap { movieId in + guard let data: String = self.readValue(for: movieId) else { return nil } + do { + let trendingViewModel = try self.decode(Data(data.utf8)) + return trendingViewModel + } catch { + return nil + } + } + } + + private func encode(_ value: TrendingViewModel) -> String { + let jsonDecoder = JSONEncoder() + do { + let data = try jsonDecoder.encode(value.getTrendingResult()) + return String(data: data, encoding: .utf8) ?? "" + } catch { + return "" + } + } + + private func decode(_ value: Data) throws -> TrendingViewModel { + let jsonDecoder = JSONDecoder() + do { + let data = try jsonDecoder.decode(TrendingResult.self, from: value) + return TrendingViewModel(trending: data) + } + } + + private func saveValue(for key: String, value: Any) { + userDefaults.set(value, forKey: key) + } + + private func readValue(for key: String) -> T? { + let value = userDefaults.value(forKey: key) as? T + return value + } + + private func removeValue(for key: String) { + userDefaults.removeObject(forKey: key) + } +} diff --git a/MyMovies/Model/Favorite/FavoriteViewModel.swift b/MyMovies/Model/Favorite/FavoriteViewModel.swift new file mode 100644 index 00000000..0a256868 --- /dev/null +++ b/MyMovies/Model/Favorite/FavoriteViewModel.swift @@ -0,0 +1,50 @@ +// +// FavoriteViewModel.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import Foundation + +class FavoriteViewModel { + + // MARK: - Properties + + private let favoriteRepository: FavoriteRepository + private var favorites: [TrendingViewModel] = [] + + var numberOfRowsInSection: Int { + self.favorites.count + } + + let titlePage: String = "Favorites" + + // MARK: - Initializer + + init(favoriteRepository: FavoriteRepository) { + self.favoriteRepository = favoriteRepository + } + + // MARK: - Functions + + func fetchFavorites(completion: @escaping (FavoriteViewModel?) -> ()) { + DispatchQueue.global().async { + self.favorites.removeAll() + self.favorites = self.favoriteRepository.getAllFavorites() + + DispatchQueue.main.async { + completion(self) + } + } + } + + func getTrending(_ index: Int) -> TrendingViewModel { + return self.favorites[index] + } + + func removeFavorite(for movieId: Int64) { + self.favorites.removeAll(where: { $0.movieId == movieId }) + favoriteRepository.deleteMovie(for: movieId) + } +} diff --git a/MyMovies/Model/MovieViewModel.swift b/MyMovies/Model/MovieViewModel.swift new file mode 100644 index 00000000..4dd8e4ef --- /dev/null +++ b/MyMovies/Model/MovieViewModel.swift @@ -0,0 +1,87 @@ +// +// MovieViewModel.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/13/23. +// + +import Foundation + +class MovieViewModel { + + // MARK: - Properties + + private var movie: Movie? = nil + private var movieId: Int64 + + private let apiLoader: APILoader + + var title: String { + self.movie?.title ?? self.movie?.originalTitle ?? "" + } + + var imageUrl: URL? { + URL(string: APIPath().fetchImage(width: "w185", imagePath: movie?.posterPath ?? "")) + } + + var genres: [String] { + movie?.genres.map { $0.name } ?? [] + } + + var overview: String { + movie?.overview ?? "" + } + + var rate: String { + guard let rate = movie?.voteAverage else { return "" } + return String(format: "%.2f", rate) + } + + var totalVoteCount: String { + guard let voteCount = movie?.voteCount else { return "" } + return String(voteCount) + } + + var releaseDate: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dddd" + guard let date = dateFormatter.date(from: movie?.releaseDate ?? "") else { return "" } + dateFormatter.dateFormat = "MM/dd/yyyy" + return dateFormatter.string(from: date) + } + + // MARK: - Initializer + + init(apiLoader: APILoader, movieId: Int64) { + self.apiLoader = apiLoader + self.movieId = movieId + } + + // MARK: - Functions + + func fetchMovie( + completion: @escaping (MovieViewModel?, ServiceError?) -> () + ) { + apiLoader.loadAPIRequest(requestData: movieId) { [weak self] movieResponse, error in + guard let self = self else { + return + } + + if let _ = error { + DispatchQueue.main.async { + completion(nil, error) + } + } else if let movieResponse = movieResponse { + self.movie = movieResponse + + DispatchQueue.main.async { + completion(self, nil) + } + } else { + DispatchQueue.main.async { + completion(nil, ServiceError(message: "Service Error: Unknow Error")) + } + } + } + } +} diff --git a/MyMovies/Model/TopRatedListViewModel.swift b/MyMovies/Model/TopRatedListViewModel.swift new file mode 100644 index 00000000..24abd49e --- /dev/null +++ b/MyMovies/Model/TopRatedListViewModel.swift @@ -0,0 +1,70 @@ +// +// TopRatedListViewModel.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/11/23. +// + +import UIKit + +class TopRatedListViewModel { + + // MARK: - Properties + + private var topRated: [TrendingViewModel] = [] + + private let favoriteRepository: FavoriteRepository + private let apiLoader: APILoader + + var numberOfRowsInSection: Int { + self.topRated.count + } + + var titlePage: String = "Top Rated" + + // MARK: - Initializer + + init(apiLoader: APILoader, favoriteRepository: FavoriteRepository = FavoriteRepository()) { + self.apiLoader = apiLoader + self.favoriteRepository = favoriteRepository + } + + // MARK: - Functions + + func fetchTopRated( + completion: @escaping (TopRatedListViewModel?, ServiceError?) -> () + ) { + apiLoader.loadAPIRequest(requestData: nil) { [weak self] topRatedResponse, error in + guard let self = self else { + return + } + + if let _ = error { + DispatchQueue.main.async { + completion(nil, error) + } + } else if let topRatedResponse = topRatedResponse { + self.topRated = topRatedResponse.results?.map({ topRated in + TrendingViewModel(trending: topRated) + }) ?? [] + + DispatchQueue.main.async { + completion(self, nil) + } + } else { + DispatchQueue.main.async { + completion(nil, ServiceError(message: "Service Error: Unknow Error")) + } + } + } + } + + func getTrending(_ index: Int) -> TrendingViewModel { + return self.topRated[index] + } + + func insertFavorite(for movieId: Int64) { + guard let trending = self.topRated.first(where: { $0.movieId == movieId }) else { return } + favoriteRepository.insetOrUpdateMovie(for: movieId, trending) + } +} diff --git a/MyMovies/Model/TrendingListViewModel.swift b/MyMovies/Model/TrendingListViewModel.swift new file mode 100644 index 00000000..bc83afe5 --- /dev/null +++ b/MyMovies/Model/TrendingListViewModel.swift @@ -0,0 +1,71 @@ +// +// TrendingListViewModel.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +class TrendingListViewModel { + + // MARK: - Properties + + private var trendings: [TrendingViewModel] = [] + + private let favoriteRepository: FavoriteRepository + private let apiLoader: APILoader + + var numberOfRowsInSection: Int { + self.trendings.count + } + + var titlePage: String = "Trending" + + // MARK: - Initializer + + init(apiLoader: APILoader, favoriteRepository: FavoriteRepository = FavoriteRepository()) { + self.apiLoader = apiLoader + self.favoriteRepository = favoriteRepository + } + + // MARK: - Functions + + func fetchTrendings( + of param: TrendingParams, + completion: @escaping (TrendingListViewModel?, ServiceError?) -> () + ) { + apiLoader.loadAPIRequest(requestData: param) { [weak self] trendingResponse, error in + guard let self = self else { + return + } + + if let _ = error { + DispatchQueue.main.async { + completion(nil, error) + } + } else if let trendingResponse = trendingResponse { + self.trendings = trendingResponse.results?.map({ trending in + TrendingViewModel(trending: trending) + }) ?? [] + + DispatchQueue.main.async { + completion(self, nil) + } + } else { + DispatchQueue.main.async { + completion(nil, ServiceError(message: "Service Error: Unknow Error")) + } + } + } + } + + func getTrending(_ index: Int) -> TrendingViewModel { + return self.trendings[index] + } + + func insertFavorite(for movieId: Int64) { + guard let trending = self.trendings.first(where: { $0.movieId == movieId }) else { return } + favoriteRepository.insetOrUpdateMovie(for: movieId, trending) + } +} diff --git a/MyMovies/Model/TrendingViewModel.swift b/MyMovies/Model/TrendingViewModel.swift new file mode 100644 index 00000000..33ed070e --- /dev/null +++ b/MyMovies/Model/TrendingViewModel.swift @@ -0,0 +1,39 @@ +// +// TrendingViewModel.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +class TrendingViewModel { + + // MARK: - Properties + + private var trending: TrendingResult + + var title: String { + self.trending.originalTitle ?? self.trending.name ?? "" + } + + var posterImageURL: URL? { + URL(string: APIPath().fetchImage(width: "w154", imagePath: trending.posterPath ?? "")) + } + + var movieId: Int64? { + self.trending.id + } + + // MARK: - Initializer + + init(trending: TrendingResult) { + self.trending = trending + } + + // MARK: - Functions + + func getTrendingResult() -> TrendingResult { + return trending + } +} diff --git a/MyMovies/Network/APIEnvironment.swift b/MyMovies/Network/APIEnvironment.swift new file mode 100644 index 00000000..584e6eb4 --- /dev/null +++ b/MyMovies/Network/APIEnvironment.swift @@ -0,0 +1,34 @@ +// +// APIEnvironment.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +enum APIEnvironment { + case development + case production + + func baseURL() -> String { + switch self { + case .development, .production: + return Bundle.main.infoDictionary?["BASE_URL"] as? String ?? "" + } + } + + func baseImageURL() -> String { + switch self { + case .development, .production: + return Bundle.main.infoDictionary?["BASE_IMAGE_URL"] as? String ?? "" + } + } + + func apiKey() -> String { + switch self { + case .development, .production: + return Bundle.main.infoDictionary?["BASE_API_KEY"] as? String ?? "" + } + } +} diff --git a/MyMovies/Network/APIHandler.swift b/MyMovies/Network/APIHandler.swift new file mode 100644 index 00000000..6f965650 --- /dev/null +++ b/MyMovies/Network/APIHandler.swift @@ -0,0 +1,20 @@ +// +// APIHandler.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +protocol RequestHandler { + associatedtype RequestDataType + func makeRequest(from data: RequestDataType) -> URLRequest? +} + +protocol ResponseHandler { + associatedtype ResponseDataType + func parseResponse(data: Data, response: HTTPURLResponse) throws -> ResponseDataType +} + +typealias APIHandler = RequestHandler & ResponseHandler diff --git a/MyMovies/Network/APILoader.swift b/MyMovies/Network/APILoader.swift new file mode 100644 index 00000000..4cee4ceb --- /dev/null +++ b/MyMovies/Network/APILoader.swift @@ -0,0 +1,55 @@ +// +// APILoader.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +struct APILoader { + let apiHandler: T + let urlSession: URLSession + + init(apiHandler: T, urlSession: URLSession = .shared) { + self.apiHandler = apiHandler + self.urlSession = urlSession + } + + func loadAPIRequest(requestData: T.RequestDataType, completion: @escaping (T.ResponseDataType?, ServiceError?) -> ()) { + guard let urlRequest = apiHandler.makeRequest(from: requestData) else { + completion(nil, ServiceError(message: "Error when performing request")) + return + } + + urlSession.dataTask(with: urlRequest) { data, response, error in + guard let httpResponse = response as? HTTPURLResponse else { + completion(nil, ServiceError(message: "Error when performing request")) + return + } + + guard error == nil, + let responseData = data else { + completion( + nil, + ServiceError(message: "Service Error: \(error?.localizedDescription ?? "Unknow Error")") + ) + return + } + + do { + let parsedResponse = try self.apiHandler.parseResponse(data: responseData, response: httpResponse) + completion(parsedResponse, nil) + } catch { + if error is ServiceError { + completion(nil, error as? ServiceError) + } else { + completion( + nil, + ServiceError(message: "Error when decoding json: \(error.localizedDescription)") + ) + } + } + }.resume() + } +} diff --git a/MyMovies/Network/APIPath.swift b/MyMovies/Network/APIPath.swift new file mode 100644 index 00000000..7dd41c08 --- /dev/null +++ b/MyMovies/Network/APIPath.swift @@ -0,0 +1,59 @@ +// +// APIPath.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +struct APIPath { + + let environment: APIEnvironment + let baseURL: String + let baseImageURL: String + let apiKey: String + + init() { + #if DEBUG + environment = APIEnvironment.development + #else + environment = APIEnvironment.production + #endif + + baseURL = environment.baseURL() + baseImageURL = environment.baseImageURL() + apiKey = environment.apiKey() + } + + func fetchTrending(with params: TrendingParams) -> String { + let url = "\(baseURL)/\(apiVersion())/trending/\(params.mediaType.rawValue)/\(params.timeWindow.rawValue)" + return applyApiKey(url) + } + + func fetchImage(width: String, imagePath: String) -> String { + let url = "\(baseImageURL)/\(width)\(imagePath)" + return applyApiKey(url) + } + + func fetchTopRated() -> String { + let url = "\(baseURL)/\(apiVersion())/movie/top_rated" + return applyApiKey(url) + } + + func fetchMovieDetails(for movieId: Int64) -> String { + let url = "\(baseURL)/\(apiVersion())/movie/\(movieId)" + return applyApiKey(url) + } + + private func applyApiKey(_ url: String) -> String { + return "\(url)?api_key=\(apiKey)" + } + + private func apiVersion() -> String { + return "3" + } +} + + + diff --git a/MyMovies/Network/ResponseHandler+Extension.swift b/MyMovies/Network/ResponseHandler+Extension.swift new file mode 100644 index 00000000..ed2dcaac --- /dev/null +++ b/MyMovies/Network/ResponseHandler+Extension.swift @@ -0,0 +1,25 @@ +// +// ResponseHandler+Extension.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +extension ResponseHandler { + func defaultParseResponse(data: Data, response: HTTPURLResponse) throws -> T { + let jsonDecoder = JSONDecoder() + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + if response.statusCode == 200 { + do { + let body = try jsonDecoder.decode(T.self, from: data) + return body + } catch { + throw ServiceError(message: "Error when decoding json: \(error.localizedDescription)") + } + } else { + throw ServiceError(message: "Error when communicating with API") + } + } +} diff --git a/MyMovies/Network/ServiceError.swift b/MyMovies/Network/ServiceError.swift new file mode 100644 index 00000000..01933bd9 --- /dev/null +++ b/MyMovies/Network/ServiceError.swift @@ -0,0 +1,12 @@ +// +// ServiceError.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +struct ServiceError: Error { + let message: String +} diff --git a/MyMovies/SceneDelegate.swift b/MyMovies/SceneDelegate.swift new file mode 100644 index 00000000..31406738 --- /dev/null +++ b/MyMovies/SceneDelegate.swift @@ -0,0 +1,64 @@ +// +// SceneDelegate.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let windowScene = (scene as? UIWindowScene) else { return } + + let navigationController = UINavigationController() + + let coordinator = MainCoordinator(navigationController: navigationController) + + let window = UIWindow(frame: windowScene.coordinateSpace.bounds) + + window.windowScene = windowScene + window.rootViewController = navigationController + window.makeKeyAndVisible() + self.window = window + + coordinator.start() + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/MyMovies/Service/ImageLoader/ImageLoader.swift b/MyMovies/Service/ImageLoader/ImageLoader.swift new file mode 100644 index 00000000..af0b50e2 --- /dev/null +++ b/MyMovies/Service/ImageLoader/ImageLoader.swift @@ -0,0 +1,50 @@ +// +// ImageLoader.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +class ImageLoader { + private var loadedImages = [URL: UIImage]() + private var runningRequests = [UUID: URLSessionDataTask]() + + func loadImage(_ url: URL, _ completion: @escaping (Result) -> Void) -> UUID? { + if let image = loadedImages[url] { + completion(.success(image)) + return nil + } + + let uuid = UUID() + + let task = URLSession.shared.dataTask(with: url) { data, response, error in + defer {self.runningRequests.removeValue(forKey: uuid) } + + if let data = data, let image = UIImage(data: data) { + self.loadedImages[url] = image + completion(.success(image)) + return + } + + guard let error = error else { + return + } + + guard (error as NSError).code == NSURLErrorCancelled else { + completion(.failure(error)) + return + } + } + task.resume() + + runningRequests[uuid] = task + return uuid + } + + func cancelLoad(_ uuid: UUID) { + runningRequests[uuid]?.cancel() + runningRequests.removeValue(forKey: uuid) + } +} diff --git a/MyMovies/Service/ImageLoader/UIImageLoader.swift b/MyMovies/Service/ImageLoader/UIImageLoader.swift new file mode 100644 index 00000000..7f536902 --- /dev/null +++ b/MyMovies/Service/ImageLoader/UIImageLoader.swift @@ -0,0 +1,42 @@ +// +// ImageLoader.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +class UIImageLoader { + static let loader = UIImageLoader() + + private let imageLoader = ImageLoader() + private var uuidMap = [UIImageView: UUID]() + + private init() {} + + func load(_ url: URL, for imageView: UIImageView) { + let token = imageLoader.loadImage(url) { result in + defer { self.uuidMap.removeValue(forKey: imageView) } + do { + let image = try result.get() + DispatchQueue.main.async { + imageView.image = image + } + } catch { + // handle the error + } + } + + if let token = token { + uuidMap[imageView] = token + } + } + + func cancel(for imageView: UIImageView) { + if let uuid = uuidMap[imageView] { + imageLoader.cancelLoad(uuid) + uuidMap.removeValue(forKey: imageView) + } + } +} diff --git a/MyMovies/Service/ImageLoader/UIImageView+ImageLoader.swift b/MyMovies/Service/ImageLoader/UIImageView+ImageLoader.swift new file mode 100644 index 00000000..a053495b --- /dev/null +++ b/MyMovies/Service/ImageLoader/UIImageView+ImageLoader.swift @@ -0,0 +1,18 @@ +// +// UIImageView+ImageLoader.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +extension UIImageView { + func loadImage(at url: URL) { + UIImageLoader.loader.load(url, for: self) + } + + func cancelImageLoad() { + UIImageLoader.loader.cancel(for: self) + } +} diff --git a/MyMovies/Service/MovieAPI.swift b/MyMovies/Service/MovieAPI.swift new file mode 100644 index 00000000..78c5b594 --- /dev/null +++ b/MyMovies/Service/MovieAPI.swift @@ -0,0 +1,24 @@ +// +// MovieAPI.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/13/23. +// + +import Foundation + +struct MovieAPI: APIHandler { + func makeRequest(from movieId: Int64) -> URLRequest? { + let urlString = APIPath().fetchMovieDetails(for: movieId) + if let url = URL(string: urlString) { + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "GET" + return urlRequest + } + return nil + } + + func parseResponse(data: Data, response: HTTPURLResponse) throws -> Movie { + return try defaultParseResponse(data: data, response: response) + } +} diff --git a/MyMovies/Service/TopRatedAPI.swift b/MyMovies/Service/TopRatedAPI.swift new file mode 100644 index 00000000..32b8b6b5 --- /dev/null +++ b/MyMovies/Service/TopRatedAPI.swift @@ -0,0 +1,24 @@ +// +// TopRatedAPI.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/11/23. +// + +import Foundation + +struct TopRatedAPI: APIHandler { + func makeRequest(from params: [String : Any]?) -> URLRequest? { + let urlString = APIPath().fetchTopRated() + if let url = URL(string: urlString) { + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "GET" + return urlRequest + } + return nil + } + + func parseResponse(data: Data, response: HTTPURLResponse) throws -> TrendingDTO { + return try defaultParseResponse(data: data, response: response) + } +} diff --git a/MyMovies/Service/TrendingAPI.swift b/MyMovies/Service/TrendingAPI.swift new file mode 100644 index 00000000..e56c6c60 --- /dev/null +++ b/MyMovies/Service/TrendingAPI.swift @@ -0,0 +1,41 @@ +// +// TrendingAPI.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import Foundation + +enum MediaType: String { + case all = "all" + case movie = "movie" + case tv = "tv" + case person = "person" +} + +enum TimeWindow: String { + case day = "day" + case week = "week" +} + +struct TrendingParams { + let mediaType: MediaType + let timeWindow: TimeWindow +} + +struct TrendingAPI: APIHandler { + func makeRequest(from params: TrendingParams) -> URLRequest? { + let urlString = APIPath().fetchTrending(with: params) + if let url = URL(string: urlString) { + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = "GET" + return urlRequest + } + return nil + } + + func parseResponse(data: Data, response: HTTPURLResponse) throws -> TrendingDTO { + return try defaultParseResponse(data: data, response: response) + } +} diff --git a/MyMovies/View/Button/Button.swift b/MyMovies/View/Button/Button.swift new file mode 100644 index 00000000..d2c5ae83 --- /dev/null +++ b/MyMovies/View/Button/Button.swift @@ -0,0 +1,42 @@ +// +// Button.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import UIKit + +class Button: UIButton { + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + updateBackgroundColor() + layer.cornerRadius = 15 + setTitleColor(.white, for: .normal) + } + + override var isHighlighted: Bool { + didSet { + updateBackgroundColor() + } + } + + private func updateBackgroundColor() { + if isEnabled { + if isHighlighted { + backgroundColor = UIColor(red: 0.10, green: 0.21, blue: 0.56, alpha: 1.00) + } else { + backgroundColor = .systemBlue + } + } + } +} diff --git a/MyMovies/View/Home/HomeView.swift b/MyMovies/View/Home/HomeView.swift new file mode 100644 index 00000000..e1f7f2d2 --- /dev/null +++ b/MyMovies/View/Home/HomeView.swift @@ -0,0 +1,103 @@ +// +// HomeView.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +protocol FavoriteButtonDelegate: AnyObject { + func favoriteMoviesButtonClicked() +} + +class HomeView: UIView { + + // MARK: - SubViews + + private var trendingView: UIView + private var topRatedView: UIView + + private lazy var favoritesButton: Button = { + let button = Button() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("Favorite Movies", for: .normal) + button.addTarget(self, action: #selector(favoriteMoviesButtonClicked), for: .touchUpInside) + return button + }() + + // MARK: - Properties + + weak var delegate: FavoriteButtonDelegate? + + // MARK: - Initializer + + init( + trendingViewController: MovieListViewController, + topRatedViewController: MovieListViewController + ) { + self.trendingView = trendingViewController.view + self.topRatedView = topRatedViewController.view + super.init(frame: CGRect()) + + setup() + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - View Setup + + private func setup() { + self.backgroundColor = .systemBackground + + setupFavoriteButton() + } + + private func setupFavoriteButton() { + addSubview(favoritesButton) + + NSLayoutConstraint.activate([ + favoritesButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20), + favoritesButton.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -20), + favoritesButton.widthAnchor.constraint(equalToConstant: 160) + ]) + + setupTrendingView() + } + + private func setupTrendingView() { + addSubview(trendingView) + + trendingView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + trendingView.topAnchor.constraint(equalTo: favoritesButton.bottomAnchor, constant: 10), + trendingView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4), + trendingView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4), + trendingView.heightAnchor.constraint(equalToConstant: 350) + ]) + + setupTopRatedView() + } + + private func setupTopRatedView() { + addSubview(topRatedView) + + topRatedView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + topRatedView.topAnchor.constraint(equalTo: trendingView.bottomAnchor), + topRatedView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4), + topRatedView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4), + topRatedView.heightAnchor.constraint(equalToConstant: 350) + ]) + } + + // MARK: - Selectors + + @objc func favoriteMoviesButtonClicked() { + delegate?.favoriteMoviesButtonClicked() + } +} diff --git a/MyMovies/View/Home/HomeViewController.swift b/MyMovies/View/Home/HomeViewController.swift new file mode 100644 index 00000000..dbfdb63b --- /dev/null +++ b/MyMovies/View/Home/HomeViewController.swift @@ -0,0 +1,66 @@ +// +// HomeViewController.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +class HomeViewController: UIViewController, Coordinating { + + // MARK: - Properties + + var coordinator: Coordinator? + + private var trendingViewController: MovieListViewController + private var topRatedViewController: MovieListViewController + + private var homeView: HomeView? = nil + + // MARK: - Initializer + + init( + trendingViewController: MovieListViewController, + topRatedViewController: MovieListViewController, + coordinator: Coordinator + ) { + self.trendingViewController = trendingViewController + self.topRatedViewController = topRatedViewController + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - View Lifecycle + + override func loadView() { + self.homeView = HomeView( + trendingViewController: trendingViewController, + topRatedViewController: topRatedViewController + ) + self.homeView?.delegate = self + view = homeView + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.isNavigationBarHidden = true + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.navigationController?.isNavigationBarHidden = false + } +} + +// MARK: - FavoriteButtonDelegate implementation + +extension HomeViewController: FavoriteButtonDelegate { + func favoriteMoviesButtonClicked() { + self.coordinator?.eventOccurred(with: .favoriteClicked, parameters: nil) + } +} diff --git a/MyMovies/View/Movie/MovieView.swift b/MyMovies/View/Movie/MovieView.swift new file mode 100644 index 00000000..b996cd75 --- /dev/null +++ b/MyMovies/View/Movie/MovieView.swift @@ -0,0 +1,256 @@ +// +// MovieView.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/13/23. +// + +import UIKit + +class MovieView: UIView { + + // MARK: - SubViews + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.boldSystemFont(ofSize: 30) + label.numberOfLines = 0 + label.textAlignment = .center + return label + }() + + private lazy var posterImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.isUserInteractionEnabled = false + return imageView + }() + + private lazy var genresStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 8 + stackView.alignment = .center + stackView.distribution = .fillEqually + return stackView + }() + + private lazy var overviewTextView: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.systemFont(ofSize: 17) + label.numberOfLines = 0 + return label + }() + + private lazy var rateAndReleaseDateStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 8 + stackView.alignment = .center + stackView.distribution = .fillEqually + return stackView + }() + + private lazy var rateLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.boldSystemFont(ofSize: 20) + return label + }() + + private lazy var totalVoteCountLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.systemFont(ofSize: 20) + return label + }() + + private lazy var releaseDateLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.systemFont(ofSize: 20) + return label + }() + + private lazy var activityIndicator: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.hidesWhenStopped = true + return activityIndicator + }() + + private lazy var errorLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.systemFont(ofSize: 28) + label.text = "Error when loading Movie details" + label.numberOfLines = 0 + label.textAlignment = .center + return label + }() + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: CGRect()) + + setup() + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - View Setup + + private func setup() { + self.backgroundColor = .systemBackground + + setupTitleLabel() + } + + private func setupTitleLabel() { + addSubview(titleLabel) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 10), + titleLabel.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -10) + ]) + + setupPosterImage() + } + + private func setupPosterImage() { + addSubview(posterImageView) + + NSLayoutConstraint.activate([ + posterImageView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 20), + posterImageView.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + posterImageView.widthAnchor.constraint(equalToConstant: 185), + posterImageView.heightAnchor.constraint(equalToConstant: 278) + ]) + + setupGenresStackView() + } + + private func setupGenresStackView() { + addSubview(genresStackView) + + NSLayoutConstraint.activate([ + genresStackView.topAnchor.constraint(equalTo: posterImageView.bottomAnchor, constant: 20), + genresStackView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 10), + genresStackView.widthAnchor.constraint(equalToConstant: 120) + ]) + + setupRateAndReleaseDateStackView() + } + + private func setupRateAndReleaseDateStackView() { + addSubview(rateAndReleaseDateStackView) + rateAndReleaseDateStackView.addArrangedSubview(rateLabel) + rateAndReleaseDateStackView.addArrangedSubview(totalVoteCountLabel) + rateAndReleaseDateStackView.addArrangedSubview(releaseDateLabel) + + NSLayoutConstraint.activate([ + rateAndReleaseDateStackView.topAnchor.constraint(equalTo: posterImageView.bottomAnchor, constant: 20), + rateAndReleaseDateStackView.leadingAnchor.constraint(equalTo: genresStackView.trailingAnchor, constant: 10), + rateAndReleaseDateStackView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -10) + ]) + + setupOverviewTextView() + } + + private func setupOverviewTextView() { + addSubview(overviewTextView) + + NSLayoutConstraint.activate([ + overviewTextView.topAnchor.constraint(equalTo: genresStackView.bottomAnchor, constant: 20), + overviewTextView.topAnchor.constraint(equalTo: rateAndReleaseDateStackView.bottomAnchor, constant: 20), + overviewTextView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 10), + overviewTextView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -10), + overviewTextView.heightAnchor.constraint(lessThanOrEqualToConstant: 200) + ]) + + setupActivityIndicator() + } + + private func setupActivityIndicator() { + addSubview(activityIndicator) + + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor) + ]) + + setupErrorLabel() + } + + private func setupErrorLabel() { + addSubview(errorLabel) + + NSLayoutConstraint.activate([ + errorLabel.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + errorLabel.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor) + ]) + } + + // MARK: - Renders + + func renderLoadingState() { + self.titleLabel.isHidden = true + self.posterImageView.isHidden = true + self.genresStackView.isHidden = true + self.overviewTextView.isHidden = true + self.rateAndReleaseDateStackView.isHidden = true + self.errorLabel.isHidden = true + self.activityIndicator.startAnimating() + } + + func renderErrorState() { + self.activityIndicator.stopAnimating() + self.titleLabel.isHidden = true + self.posterImageView.isHidden = true + self.genresStackView.isHidden = true + self.overviewTextView.isHidden = true + self.rateAndReleaseDateStackView.isHidden = true + self.errorLabel.isHidden = false + } + + func renderSuccessState(with viewModel: MovieViewModel) { + self.activityIndicator.stopAnimating() + self.errorLabel.isHidden = true + self.titleLabel.isHidden = false + self.posterImageView.isHidden = false + self.genresStackView.isHidden = false + self.overviewTextView.isHidden = false + self.rateAndReleaseDateStackView.isHidden = false + + self.titleLabel.text = viewModel.title + if let url = viewModel.imageUrl { + self.posterImageView.loadImage(at: url) + } + for genre in viewModel.genres { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.systemFont(ofSize: 15) + label.text = genre + genresStackView.addArrangedSubview(label) + } + self.overviewTextView.text = viewModel.overview + self.rateLabel.text = "\(viewModel.rate)/10" + self.totalVoteCountLabel.text = "\(viewModel.totalVoteCount) votes" + self.releaseDateLabel.text = "Released in \(viewModel.releaseDate)" + } +} diff --git a/MyMovies/View/Movie/MovieViewController.swift b/MyMovies/View/Movie/MovieViewController.swift new file mode 100644 index 00000000..93c0ca03 --- /dev/null +++ b/MyMovies/View/Movie/MovieViewController.swift @@ -0,0 +1,55 @@ +// +// MovieViewController.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/13/23. +// + +import UIKit + +class MovieViewController: UIViewController { + + // MARK: - Properties + + private var movieView: MovieView? = nil + private var movieViewModel: MovieViewModel + + // MARK: - Initializer + + init( + movieViewModel: MovieViewModel + ) { + self.movieViewModel = movieViewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - View Lifecycle + + override func loadView() { + self.movieView = MovieView() + view = movieView + } + + override func viewDidLoad() { + super.viewDidLoad() + + movieView?.renderLoadingState() + + movieViewModel.fetchMovie { [weak self] movieViewModel, error in + guard let self = self else { + return + } + + if error != nil { + self.movieView?.renderErrorState() + } else if let movieViewModel = movieViewModel { + self.movieViewModel = movieViewModel + self.movieView?.renderSuccessState(with: movieViewModel) + } + } + } +} diff --git a/MyMovies/View/Trending/MovieListCollectionViewCell.swift b/MyMovies/View/Trending/MovieListCollectionViewCell.swift new file mode 100644 index 00000000..66dda82f --- /dev/null +++ b/MyMovies/View/Trending/MovieListCollectionViewCell.swift @@ -0,0 +1,228 @@ +// +// MovieListCollectionViewCell.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +class MovieListCollectionViewCell: UICollectionViewCell { + + // MARK: - SubViews + + static let identifier = "MovieListCollectionViewCell" + + private var movieId: Int64? + + private lazy var favoriteImageView: UIImageView = { + let image = UIImage(systemName: "star.fill") + let imageView = UIImageView(image: image) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .systemYellow + imageView.isHidden = true + return imageView + }() + + private lazy var removeImageView: UIImageView = { + let image = UIImage(systemName: "trash.fill") + let imageView = UIImageView(image: image) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .systemYellow + imageView.isHidden = true + return imageView + }() + + private lazy var favoriteBackground: UIView = { + let view = UIView() + view.backgroundColor = .systemRed + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var removeBackground: UIView = { + let view = UIView() + view.backgroundColor = .systemRed + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var containerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var posterImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.isUserInteractionEnabled = false + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.systemFont(ofSize: 18) + label.numberOfLines = 3 + return label + }() + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: CGRect()) + + setup() + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - View Setup + + private func setup() { + self.backgroundColor = .systemBackground + + setupContainerView() + } + + private func setupContainerView() { + addSubview(containerView) + + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + setupPosterImage() + } + + private func setupPosterImage() { + containerView.addSubview(posterImageView) + + NSLayoutConstraint.activate([ + posterImageView.topAnchor.constraint(equalTo: containerView.topAnchor), + posterImageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + posterImageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + posterImageView.widthAnchor.constraint(equalToConstant: 154), + posterImageView.heightAnchor.constraint(equalToConstant: 231) + ]) + + setupTitleLabel() + } + + private func setupTitleLabel() { + containerView.addSubview(titleLabel) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: posterImageView.bottomAnchor, constant: 5), + titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 2), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -2) + ]) + + setupFavoriteButton() + } + + private func setupFavoriteButton() { + favoriteBackground.addSubview(favoriteImageView) + addSubview(favoriteBackground) + + NSLayoutConstraint.activate([ + favoriteBackground.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + favoriteBackground.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), + favoriteBackground.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor), + favoriteImageView.centerXAnchor.constraint(equalTo: favoriteBackground.centerXAnchor), + favoriteImageView.centerYAnchor.constraint(equalTo: favoriteBackground.centerYAnchor) + ]) + + setupRemoveButton() + } + + private func setupRemoveButton() { + removeBackground.addSubview(removeImageView) + addSubview(removeBackground) + + NSLayoutConstraint.activate([ + removeBackground.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + removeBackground.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor), + removeBackground.bottomAnchor.constraint(equalTo: posterImageView.bottomAnchor), + removeImageView.centerXAnchor.constraint(equalTo: removeBackground.centerXAnchor), + removeImageView.centerYAnchor.constraint(equalTo: removeBackground.centerYAnchor) + ]) + } + + // MARK: - Public Functions + + func setupData(model: TrendingViewModel) { + titleLabel.text = model.title + movieId = model.movieId + if let url = model.posterImageURL { + posterImageView.loadImage(at: url) + } + } + + func getMovieId() -> Int64 { + return movieId ?? 0 + } + + func animateFavorite(for direction: UISwipeGestureRecognizer.Direction) { + var frame = containerView.frame + + if direction == .down { + + frame.origin.y += 75.0 + self.favoriteImageView.isHidden = false + + UIView.animate(withDuration: 0.30) { + self.containerView.frame = frame + self.favoriteBackground.frame.size.height = 75 + self.favoriteImageView.frame.origin.y = 40 + } + + frame.origin.y -= 75.0 + + UIView.animate(withDuration: 0.30, delay: 0.30) { + self.containerView.frame = frame + self.favoriteBackground.frame.size.height = 0 + self.favoriteImageView.frame.origin.y = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.50) { + self.favoriteImageView.isHidden = true + } + + } else { + frame.origin.x += 75.0 + self.removeImageView.isHidden = false + + UIView.animate(withDuration: 0.30) { + self.containerView.frame = frame + self.removeBackground.frame.size.width = 75 + self.removeImageView.frame.origin.x = 50 + } + + + frame.origin.x -= 75.0 + + UIView.animate(withDuration: 0.30, delay: 0.30) { + self.containerView.frame = frame + self.removeBackground.frame.size.width = 0 + self.removeImageView.frame.origin.x = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.50) { + self.removeImageView.isHidden = true + } + } + } + + override func prepareForReuse() { + posterImageView.image = nil + posterImageView.cancelImageLoad() + } +} diff --git a/MyMovies/View/Trending/MovieListView.swift b/MyMovies/View/Trending/MovieListView.swift new file mode 100644 index 00000000..ce0246d4 --- /dev/null +++ b/MyMovies/View/Trending/MovieListView.swift @@ -0,0 +1,202 @@ +// +// MovieListView.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +protocol FavoriteDelegate: AnyObject { + func favorite(_ row: Int) +} + +class MovieListView: UIView { + + // MARK: - SubViews + + private lazy var trendingLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.boldSystemFont(ofSize: 24) + return label + }() + + private lazy var trendingCollectionView: UICollectionView = { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.itemSize = CGSize(width: 154, height: 310) + layout.minimumLineSpacing = 4 + layout.minimumInteritemSpacing = 4 + let collectionView = UICollectionView(frame: CGRect(), collectionViewLayout: layout) + collectionView.showsHorizontalScrollIndicator = false + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.register(MovieListCollectionViewCell.self, forCellWithReuseIdentifier: MovieListCollectionViewCell.identifier) + return collectionView + }() + + private lazy var activityIndicator: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.hidesWhenStopped = true + return activityIndicator + }() + + private lazy var errorLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .label + label.font = UIFont.systemFont(ofSize: 28) + label.text = "Error when loading Trending information" + label.numberOfLines = 0 + label.textAlignment = .center + return label + }() + + // MARK: - Properties + + weak var collectionViewDelegate: UICollectionViewDelegate? { + get { + self.trendingCollectionView.delegate + } + set { + self.trendingCollectionView.delegate = newValue + } + } + + weak var collectionDataSource: UICollectionViewDataSource? { + get { + self.trendingCollectionView.dataSource + } + set { + self.trendingCollectionView.dataSource = newValue + } + } + + var collectionViewLeadingContraint: NSLayoutConstraint? = nil + var collectionViewTrailingContraint: NSLayoutConstraint? = nil + var collectionViewBottomContraint: NSLayoutConstraint? = nil + + // MARK: - Initializer + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - View Setup + + private func setup() { + self.backgroundColor = .systemBackground + + setupTrendingLabel() + } + + private func setupTrendingLabel() { + addSubview(trendingLabel) + + NSLayoutConstraint.activate([ + trendingLabel.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 10), + trendingLabel.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 10) + ]) + + setupTrendingCollectionView() + } + + private func setupTrendingCollectionView() { + addSubview(trendingCollectionView) + + collectionViewLeadingContraint = trendingCollectionView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor) + collectionViewTrailingContraint = trendingCollectionView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor) + collectionViewBottomContraint = trendingCollectionView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor) + + NSLayoutConstraint.activate([ + trendingCollectionView.topAnchor.constraint(equalTo: trendingLabel.bottomAnchor, constant: 10), + collectionViewLeadingContraint!, + collectionViewTrailingContraint!, + collectionViewBottomContraint! + ]) + + setupActivityIndicator() + } + + private func setupActivityIndicator() { + addSubview(activityIndicator) + + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor) + ]) + + setupErrorLabel() + } + + private func setupErrorLabel() { + addSubview(errorLabel) + + NSLayoutConstraint.activate([ + errorLabel.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + errorLabel.centerYAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor), + errorLabel.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 20), + errorLabel.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -20) + ]) + } + + // MARK: - Renders + + func renderLoadingState() { + self.trendingCollectionView.isHidden = true + self.errorLabel.isHidden = true + self.activityIndicator.startAnimating() + } + + func renderErrorState() { + self.activityIndicator.stopAnimating() + self.trendingCollectionView.isHidden = true + self.trendingLabel.isHidden = true + self.errorLabel.isHidden = false + } + + func renderSuccessState(with title: String) { + self.activityIndicator.stopAnimating() + self.errorLabel.isHidden = true + self.trendingLabel.isHidden = false + self.trendingLabel.text = title + self.trendingCollectionView.isHidden = false + self.trendingCollectionView.reloadData() + } + + func renderFavoriteState(with viewModel: FavoriteViewModel) { + self.activityIndicator.stopAnimating() + self.trendingCollectionView.reloadData() + + if viewModel.numberOfRowsInSection == 0 { + self.errorLabel.text = "You do not have any favorite, to select one just swipe down in a movie" + + self.trendingCollectionView.isHidden = true + self.trendingLabel.isHidden = true + self.errorLabel.isHidden = false + } else { + self.errorLabel.isHidden = true + self.trendingLabel.isHidden = false + self.trendingLabel.text = viewModel.titlePage + self.trendingCollectionView.isHidden = false + + collectionViewLeadingContraint?.constant = 10 + collectionViewTrailingContraint?.constant = -10 + collectionViewBottomContraint?.constant = -20 + + if let layout = self.trendingCollectionView.collectionViewLayout as? UICollectionViewFlowLayout { + layout.scrollDirection = .vertical + self.trendingCollectionView.collectionViewLayout = layout + } + + self.layoutIfNeeded() + } + } +} diff --git a/MyMovies/View/Trending/MovieListViewController.swift b/MyMovies/View/Trending/MovieListViewController.swift new file mode 100644 index 00000000..1ccf3e20 --- /dev/null +++ b/MyMovies/View/Trending/MovieListViewController.swift @@ -0,0 +1,149 @@ +// +// MovieListViewController.swift +// MyMovies +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import UIKit + +class MovieListViewController: UIViewController, Coordinating { + + // MARK: - Properties + + var coordinator: Coordinator? + + private var trendingView: MovieListView? = nil + private var trendingListViewModel: TrendingListViewModel? + private var topRatedListViewModel: TopRatedListViewModel? + private var favoriteViewModel: FavoriteViewModel? + + // MARK: - Initializer + + init( + trendingListViewModel: TrendingListViewModel? = nil, + topRatedListViewModel: TopRatedListViewModel? = nil, + favoriteViewModel: FavoriteViewModel? = nil, + coordinator: Coordinator + ) { + self.trendingListViewModel = trendingListViewModel + self.topRatedListViewModel = topRatedListViewModel + self.favoriteViewModel = favoriteViewModel + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - View Lifecycle + + override func loadView() { + self.trendingView = MovieListView() + self.trendingView?.collectionDataSource = self + self.trendingView?.collectionViewDelegate = self + view = trendingView + } + + override func viewDidLoad() { + super.viewDidLoad() + + trendingView?.renderLoadingState() + + trendingListViewModel?.fetchTrendings( + of: TrendingParams(mediaType: .movie, timeWindow: .day) + ) { [weak self] trendingListViewModel, error in + guard let self = self else { + return + } + + if error != nil { + self.trendingView?.renderErrorState() + } else if let trendingListViewModel = trendingListViewModel { + self.trendingListViewModel = trendingListViewModel + self.trendingView?.renderSuccessState(with: trendingListViewModel.titlePage) + } + } + + topRatedListViewModel?.fetchTopRated { [weak self] topRatedListViewModel, error in + guard let self = self else { + return + } + + if error != nil { + self.trendingView?.renderErrorState() + } else if let topRatedListViewModel = topRatedListViewModel { + self.topRatedListViewModel = topRatedListViewModel + self.trendingView?.renderSuccessState(with: topRatedListViewModel.titlePage) + } + } + + favoriteViewModel?.fetchFavorites { [weak self] favoriteViewModel in + guard let self = self else { + return + } + + if let favoriteViewModel = favoriteViewModel { + self.favoriteViewModel = favoriteViewModel + self.trendingView?.renderFavoriteState(with: favoriteViewModel) + } + } + } +} + +// MARK: - UICollectionViewDelegate and UICollectionViewDataSource implementation + +extension MovieListViewController: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return self.trendingListViewModel?.numberOfRowsInSection ?? + self.topRatedListViewModel?.numberOfRowsInSection ?? + self.favoriteViewModel?.numberOfRowsInSection ?? 0 + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let trending = trendingListViewModel?.getTrending(indexPath.row) ?? + topRatedListViewModel?.getTrending(indexPath.row) ?? + favoriteViewModel?.getTrending(indexPath.row) else { return UICollectionViewCell() } + + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MovieListCollectionViewCell.identifier, for: indexPath) as? MovieListCollectionViewCell + cell?.setupData(model: trending) + + let swipeUpsideDown = UISwipeGestureRecognizer(target: self, action: #selector(favorite(_:))) + swipeUpsideDown.direction = .down + cell?.addGestureRecognizer(swipeUpsideDown) + + let swipeLeftToRight = UISwipeGestureRecognizer(target: self, action: #selector(favorite(_:))) + swipeLeftToRight.direction = .right + cell?.addGestureRecognizer(swipeLeftToRight) + + return cell ?? UICollectionViewCell() + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let trending = trendingListViewModel?.getTrending(indexPath.row) ?? + topRatedListViewModel?.getTrending(indexPath.row) ?? + favoriteViewModel?.getTrending(indexPath.row), + let movieId = trending.movieId else { return } + + coordinator?.eventOccurred(with: Event.movieClicked, parameters: movieId) + } + + @objc func favorite(_ sender: UISwipeGestureRecognizer) { + guard let cell = sender.view as? MovieListCollectionViewCell else { + return + } + + cell.animateFavorite(for: sender.direction) + + self.trendingListViewModel?.insertFavorite(for: cell.getMovieId()) + self.topRatedListViewModel?.insertFavorite(for: cell.getMovieId()) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.50) { + self.favoriteViewModel?.removeFavorite(for: cell.getMovieId()) + if let favoriteViewModel = self.favoriteViewModel { + self.trendingView?.renderFavoriteState(with: favoriteViewModel) + } + } + } +} diff --git a/MyMoviesTests/Coordinator/MainCoordinatorSpec.swift b/MyMoviesTests/Coordinator/MainCoordinatorSpec.swift new file mode 100644 index 00000000..2f8dfebc --- /dev/null +++ b/MyMoviesTests/Coordinator/MainCoordinatorSpec.swift @@ -0,0 +1,43 @@ +// +// MainCoordinatorSpec.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import XCTest +@testable import MyMovies + +final class MainCoordinatorSpec: XCTestCase { + private var mainCoordinator: MainCoordinator! + private var navigationController: MockUINavigationController! + + override func setUp() { + super.setUp() + + navigationController = MockUINavigationController() + mainCoordinator = MainCoordinator(navigationController: navigationController) + } + + func testMainCoordinatorStart() { + mainCoordinator.start() + + XCTAssertTrue(navigationController.setViewControllersCalled) + XCTAssertTrue(navigationController.controllers[0] is HomeViewController) + } + + func testEventMovieClicked() { + let movieId: Int64 = 238 + mainCoordinator.eventOccurred(with: .movieClicked, parameters: movieId) + + XCTAssertTrue(navigationController.pushViewControllerCalled) + XCTAssertTrue(navigationController.controllers[0] is MovieViewController) + } + + func testEventFavoritesClicked() { + mainCoordinator.eventOccurred(with: .favoriteClicked, parameters: nil) + + XCTAssertTrue(navigationController.pushViewControllerCalled) + XCTAssertTrue(navigationController.controllers[0] is MovieListViewController) + } +} diff --git a/MyMoviesTests/Helper/JsonHelper.swift b/MyMoviesTests/Helper/JsonHelper.swift new file mode 100644 index 00000000..ae489f84 --- /dev/null +++ b/MyMoviesTests/Helper/JsonHelper.swift @@ -0,0 +1,34 @@ +// +// JsonHelper.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/16/23. +// + +import Foundation +@testable import MyMovies + +class JsonHelper { + func getMockData(for path: String) -> Data? { + guard let pathString = Bundle(for: type(of: self)).path(forResource: path, ofType: "json") else { + fatalError("Mock json not found") + } + + guard let jsonString = try? String(contentsOfFile: pathString, encoding: .utf8) else { + fatalError("Mock json can not be converted to String") + } + + return jsonString.data(using: .utf8) + } + + func getMockJson(for data: Data) -> T { + let jsonDecoder = JSONDecoder() + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + do { + let body = try jsonDecoder.decode(T.self, from: data) + return body + } catch { + fatalError("Error when decoding json") + } + } +} diff --git a/MyMoviesTests/Mocks/Favorite/favorite.json b/MyMoviesTests/Mocks/Favorite/favorite.json new file mode 100644 index 00000000..cc3f207d --- /dev/null +++ b/MyMoviesTests/Mocks/Favorite/favorite.json @@ -0,0 +1,418 @@ +[ + { + "adult": false, + "backdrop_path": "/r9PkFnRUIthgBp2JZZzD380MWZy.jpg", + "id": 315162, + "title": "Puss in Boots: The Last Wish", + "original_language": "en", + "original_title": "Puss in Boots: The Last Wish", + "overview": "Puss in Boots discovers that his passion for adventure has taken its toll: He has burned through eight of his nine lives, leaving him with only one life left. Puss sets out on an epic journey to find the mythical Last Wish and restore his nine lives.", + "poster_path": "/1NqwE6LP9IEdOZ57NCT51ftHtWT.jpg", + "media_type": "movie", + "genre_ids": [ + 16, + 28, + 12, + 35, + 10751, + 14 + ], + "popularity": 9724.995, + "release_date": "2022-12-21", + "video": false, + "vote_average": 8.6, + "vote_count": 1400 + }, + { + "adult": false, + "backdrop_path": "/ng9a2oVoZuohU0Mzls9GAVH0MCn.jpg", + "id": 858408, + "title": "Dog Gone", + "original_language": "en", + "original_title": "Dog Gone", + "overview": "When his beloved dog goes missing, a young man embarks on an incredible search with his parents to find him and give him life-saving medication.", + "poster_path": "/eYWdLZCS9W1Xli9bynzFSddLx02.jpg", + "media_type": "movie", + "genre_ids": [ + 10751, + 12, + 18 + ], + "popularity": 51.754, + "release_date": "2023-01-13", + "video": false, + "vote_average": 6.7, + "vote_count": 6 + }, + { + "adult": false, + "backdrop_path": "/s16H6tpK2utvwDtzZ8Qy4qm5Emw.jpg", + "id": 76600, + "title": "Avatar: The Way of Water", + "original_language": "en", + "original_title": "Avatar: The Way of Water", + "overview": "Set more than a decade after the events of the first film, learn the story of the Sully family (Jake, Neytiri, and their kids), the trouble that follows them, the lengths they go to keep each other safe, the battles they fight to stay alive, and the tragedies they endure.", + "poster_path": "/t6HIqrRAclMCA60NsSmeqe9RmNV.jpg", + "media_type": "movie", + "genre_ids": [ + 878, + 12, + 28 + ], + "popularity": 3003.545, + "release_date": "2022-12-16", + "video": false, + "vote_average": 7.7, + "vote_count": 4094 + }, + { + "adult": false, + "backdrop_path": "/lacnOoTHyS8g0eSu7cyQ8TgdGtv.jpg", + "id": 872954, + "title": "The Old Way", + "original_language": "en", + "original_title": "The Old Way", + "overview": "An old gunslinger and his daughter must face the consequences of his past, when the son of a man he murdered years ago arrives to take his revenge.", + "poster_path": "/8HCCYAIocXxMKn7J9yQfDX1vBM5.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 37 + ], + "popularity": 24.751, + "release_date": "2023-01-06", + "video": false, + "vote_average": 5.375, + "vote_count": 8 + }, + { + "adult": false, + "backdrop_path": "/5kAGbi9MFAobQTVfK4kWPnIfnP0.jpg", + "id": 536554, + "title": "M3GAN", + "original_language": "en", + "original_title": "M3GAN", + "overview": "A brilliant toy company roboticist uses artificial intelligence to develop M3GAN, a life-like doll programmed to emotionally bond with her newly orphaned niece. But when the doll's programming works too well, she becomes overprotective of her new friend with terrifying results.", + "poster_path": "/7CNCv9uhqdwK7Fv4bR4nmDysnd9.jpg", + "media_type": "movie", + "genre_ids": [ + 878, + 27, + 35 + ], + "popularity": 2067, + "release_date": "2023-01-06", + "video": false, + "vote_average": 7.208, + "vote_count": 209 + }, + { + "adult": false, + "backdrop_path": "/b08BDQPq42AoLMfhi7DtTOoYqVu.jpg", + "id": 800815, + "title": "The Pale Blue Eye", + "original_language": "en", + "original_title": "The Pale Blue Eye", + "overview": "West Point, New York, 1830. When a cadet at the burgeoning military academy is found hanged with his heart cut out, the top brass summons former New York City constable Augustus Landor to investigate. While attempting to solve this grisly mystery, the reluctant detective engages the help of one of the cadets: a strange but brilliant young fellow by the name of Edgar Allan Poe", + "poster_path": "/9xkGlFRqrN8btTLU0KQvOfn2PHr.jpg", + "media_type": "movie", + "genre_ids": [ + 9648, + 53, + 27, + 80 + ], + "popularity": 637.882, + "release_date": "2022-12-23", + "video": false, + "vote_average": 7.138, + "vote_count": 508 + }, + { + "adult": false, + "backdrop_path": "/mSyQoValhBsJdq3JNGXJww2Q5yL.jpg", + "id": 593643, + "title": "The Menu", + "original_language": "en", + "original_title": "The Menu", + "overview": "A young couple travels to a remote island to eat at an exclusive restaurant where the chef has prepared a lavish menu, with some shocking surprises.", + "poster_path": "/fPtUgMcLIboqlTlPrq0bQpKK8eq.jpg", + "media_type": "movie", + "genre_ids": [ + 35, + 27, + 53 + ], + "popularity": 500.984, + "release_date": "2022-11-18", + "video": false, + "vote_average": 7.306, + "vote_count": 1335 + }, + { + "adult": false, + "backdrop_path": "/e1cC9muSRtAHVtF5GJtKAfATYIT.jpg", + "id": 1063242, + "title": "Disconnect: The Wedding Planner", + "original_language": "fr", + "original_title": "Disconnect: The Wedding Planner", + "overview": "After falling victim to a scam, a desperate man races the clock as he attempts to plan a luxurious destination wedding for an important investor.", + "poster_path": "/tGmCxGkVMOqig2TrbXAsE9dOVvX.jpg", + "media_type": "movie", + "genre_ids": [ + 10749, + 35 + ], + "popularity": 40.16, + "release_date": "2023-01-13", + "video": false, + "vote_average": 2, + "vote_count": 1 + }, + { + "adult": false, + "backdrop_path": "/bTqmsKOr9cSdYptFhbFEOmP3qCm.jpg", + "id": 640146, + "title": "Ant-Man and the Wasp: Quantumania", + "original_language": "en", + "original_title": "Ant-Man and the Wasp: Quantumania", + "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange new creatures and embarking on an adventure that will push them beyond the limits of what they thought possible.", + "poster_path": "/ynWwsafy7ib7hitxImQ8Lso3lvI.jpg", + "media_type": "movie", + "genre_ids": [ + 12, + 878, + 35 + ], + "popularity": 156.416, + "release_date": "2023-02-17", + "video": false, + "vote_average": 0, + "vote_count": 0 + }, + { + "adult": false, + "backdrop_path": "/5pMy5LF2JAleBNBtuzizfCMWM7k.jpg", + "id": 653851, + "title": "Devotion", + "original_language": "en", + "original_title": "Devotion", + "overview": "The harrowing true story of two elite US Navy fighter pilots during the Korean War. Their heroic sacrifices would ultimately make them the Navy's most celebrated wingmen.", + "poster_path": "/26yQPXymbWeCLKwcmyL8dRjAzth.jpg", + "media_type": "movie", + "genre_ids": [ + 10752, + 36, + 18 + ], + "popularity": 207.761, + "release_date": "2022-11-23", + "video": false, + "vote_average": 6.653, + "vote_count": 49 + }, + { + "adult": false, + "backdrop_path": "/aROq5hpE1WgDMjwIxxmk42kFFQZ.jpg", + "id": 829410, + "title": "Sick", + "original_language": "en", + "original_title": "Sick", + "overview": "In the midst of the pandemic, college student Parker and her best friend Miri decide to self-quarantine at her family's lake house, where they will be alone — or so they think...", + "poster_path": "/yisnA4KdZOfJeDve3DekgUpeCe9.jpg", + "media_type": "movie", + "genre_ids": [ + 27, + 53 + ], + "popularity": 15.085, + "release_date": "2023-01-13", + "video": false, + "vote_average": 6.4, + "vote_count": 9 + }, + { + "adult": false, + "backdrop_path": "/dKqa850uvbNSCaQCV4Im1XlzEtQ.jpg", + "id": 661374, + "title": "Glass Onion: A Knives Out Mystery", + "original_language": "en", + "original_title": "Glass Onion: A Knives Out Mystery", + "overview": "World-famous detective Benoit Blanc heads to Greece to peel back the layers of a mystery surrounding a tech billionaire and his eclectic crew of friends.", + "poster_path": "/vDGr1YdrlfbU9wxTOdpf3zChmv9.jpg", + "media_type": "movie", + "genre_ids": [ + 9648, + 53, + 35 + ], + "popularity": 1656.736, + "release_date": "2022-11-23", + "video": false, + "vote_average": 7.072, + "vote_count": 2576 + }, + { + "adult": false, + "backdrop_path": "/tt79dbOPd9Z9ykEOpvckttgYXwH.jpg", + "id": 545611, + "title": "Everything Everywhere All at Once", + "original_language": "en", + "original_title": "Everything Everywhere All at Once", + "overview": "An aging Chinese immigrant is swept up in an insane adventure, where she alone can save what's important to her by connecting with the lives she could have led in other universes.", + "poster_path": "/w3LxiVYdWWRvEVdn5RYq6jIqkb1.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 12, + 878 + ], + "popularity": 224.437, + "release_date": "2022-03-24", + "video": false, + "vote_average": 8.067, + "vote_count": 2648 + }, + { + "adult": false, + "backdrop_path": "/bQXAqRx2Fgc46uCVWgoPz5L5Dtr.jpg", + "id": 436270, + "title": "Black Adam", + "original_language": "en", + "original_title": "Black Adam", + "overview": "Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods—and imprisoned just as quickly—Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the modern world.", + "poster_path": "/pFlaoHTZeyNkG83vxsAJiGzfSsa.jpg", + "media_type": "movie", + "genre_ids": [ + 14, + 28, + 878 + ], + "popularity": 1876.042, + "release_date": "2022-10-21", + "video": false, + "vote_average": 7.218, + "vote_count": 3656 + }, + { + "adult": false, + "backdrop_path": "/iBEs4PnQLkL4BbsAGvsuBgeTCM3.jpg", + "id": 960519, + "title": "Noise", + "original_language": "es", + "original_title": "Ruido", + "overview": "Julia is a mother, or rather, one of many mothers, sisters, daughters, colleagues, who have had their lives torn apart by the widespread violence in a country waging a war against its women. Julia is searching for Ger, her daughter. And in her search, she will weave through the stories and struggles of the different women she will meet.", + "poster_path": "/6GemI78Ge0ABs5qM6CnvCPduKEU.jpg", + "media_type": "movie", + "genre_ids": [ + 18 + ], + "popularity": 122.966, + "release_date": "2022-10-13", + "video": false, + "vote_average": 7.429, + "vote_count": 7 + }, + { + "adult": false, + "backdrop_path": "/8I37NtDffNV7AZlDa7uDvvqhovU.jpg", + "id": 19995, + "title": "Avatar", + "original_language": "en", + "original_title": "Avatar", + "overview": "In the 22nd century, a paraplegic Marine is dispatched to the moon Pandora on a unique mission, but becomes torn between following orders and protecting an alien civilization.", + "poster_path": "/jRXYjXNq0Cs2TcJjLkki24MLp7u.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 12, + 14, + 878 + ], + "popularity": 1552.689, + "release_date": "2009-12-15", + "video": false, + "vote_average": 7.561, + "vote_count": 27754 + }, + { + "adult": false, + "backdrop_path": "/AaV1YIdWKnjAIAOe8UUKBFm327v.jpg", + "id": 361743, + "title": "Top Gun: Maverick", + "original_language": "en", + "original_title": "Top Gun: Maverick", + "overview": "After more than thirty years of service as one of the Navy’s top aviators, and dodging the advancement in rank that would ground him, Pete “Maverick” Mitchell finds himself training a detachment of TOP GUN graduates for a specialized mission the likes of which no living pilot has ever seen.", + "poster_path": "/62HCnUTziyWcpDaBO2i1DX17ljH.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 18 + ], + "popularity": 519.672, + "release_date": "2022-05-27", + "video": false, + "vote_average": 8.3, + "vote_count": 5597 + }, + { + "adult": false, + "backdrop_path": "/yYrvN5WFeGYjJnRzhY0QXuo4Isw.jpg", + "id": 505642, + "title": "Black Panther: Wakanda Forever", + "original_language": "en", + "original_title": "Black Panther: Wakanda Forever", + "overview": "Queen Ramonda, Shuri, M’Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T’Challa’s death. As the Wakandans strive to embrace their next chapter, the heroes must band together with the help of War Dog Nakia and Everett Ross and forge a new path for the kingdom of Wakanda.", + "poster_path": "/sv1xJUazXeYqALzczSZ3O6nkH75.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 12, + 878 + ], + "popularity": 1180.588, + "release_date": "2022-11-11", + "video": false, + "vote_average": 7.47, + "vote_count": 1604 + }, + { + "adult": false, + "backdrop_path": "/9Md4CqzUGDtK5oEkRRvozLkGc9d.jpg", + "id": 674324, + "title": "The Banshees of Inisherin", + "original_language": "en", + "original_title": "The Banshees of Inisherin", + "overview": "Two lifelong friends find themselves at an impasse when one abruptly ends their relationship, with alarming consequences for both of them.", + "poster_path": "/dpeR3ue0Q93Rk8jhE2DzVPMRGIm.jpg", + "media_type": "movie", + "genre_ids": [ + 18, + 35 + ], + "popularity": 203.388, + "release_date": "2022-10-21", + "video": false, + "vote_average": 7.474, + "vote_count": 463 + }, + { + "adult": false, + "backdrop_path": "/6RCf9jzKxyjblYV4CseayK6bcJo.jpg", + "id": 804095, + "title": "The Fabelmans", + "original_language": "en", + "original_title": "The Fabelmans", + "overview": "Growing up in post-World War II era Arizona, young Sammy Fabelman aspires to become a filmmaker as he reaches adolescence, but soon discovers a shattering family secret and explores how the power of films can help him see the truth.", + "poster_path": "/xId9zoiv5WPQOuxqykUDrlpmrUz.jpg", + "media_type": "movie", + "genre_ids": [ + 18 + ], + "popularity": 161.117, + "release_date": "2022-11-11", + "video": false, + "vote_average": 8.018, + "vote_count": 420 + } +] \ No newline at end of file diff --git a/MyMoviesTests/Mocks/MockUINavigationController.swift b/MyMoviesTests/Mocks/MockUINavigationController.swift new file mode 100644 index 00000000..f0d9f53a --- /dev/null +++ b/MyMoviesTests/Mocks/MockUINavigationController.swift @@ -0,0 +1,26 @@ +// +// UINavigationControllerMock.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import UIKit +@testable import MyMovies + +final class MockUINavigationController: UINavigationController { + var controllers: [UIViewController] = [] + + var setViewControllersCalled = false + var pushViewControllerCalled = false + + override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) { + controllers = viewControllers + setViewControllersCalled = true + } + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + controllers.insert(viewController, at: 0) + pushViewControllerCalled = true + } +} diff --git a/MyMoviesTests/Mocks/MockURLProtocol.swift b/MyMoviesTests/Mocks/MockURLProtocol.swift new file mode 100644 index 00000000..05b138f9 --- /dev/null +++ b/MyMoviesTests/Mocks/MockURLProtocol.swift @@ -0,0 +1,44 @@ +// +// MockURLProtocol.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import Foundation + +class MockURLProtocol: URLProtocol { + + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data?))? + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + fatalError("Handler is unavailable.") + } + + do { + let (response, data) = try handler(request) + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + + if let data = data { + client?.urlProtocol(self, didLoad: data) + } + + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() { + } +} diff --git a/MyMoviesTests/Mocks/MockUserDefaults.swift b/MyMoviesTests/Mocks/MockUserDefaults.swift new file mode 100644 index 00000000..b5e9f29c --- /dev/null +++ b/MyMoviesTests/Mocks/MockUserDefaults.swift @@ -0,0 +1,26 @@ +// +// MockUserDefaults.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/16/23. +// + +import Foundation +@testable import MyMovies + +class MockUserDefaults: UserDefaults { + + var persisted: [String : Any] = [:] + + override func set(_ value: Any?, forKey key: String) { + persisted[key] = value + } + + override func value(forKey key: String) -> Any? { + return persisted[key] as Any? + } + + override func removeObject(forKey defaultName: String) { + persisted[defaultName] = nil + } +} diff --git a/MyMoviesTests/Mocks/Movie/movie-error.json b/MyMoviesTests/Mocks/Movie/movie-error.json new file mode 100644 index 00000000..22c9812f --- /dev/null +++ b/MyMoviesTests/Mocks/Movie/movie-error.json @@ -0,0 +1,5 @@ +{ + "success": false, + "status_code": 34, + "status_message": "The resource you requested could not be found." +} \ No newline at end of file diff --git a/MyMoviesTests/Mocks/Movie/movie-success.json b/MyMoviesTests/Mocks/Movie/movie-success.json new file mode 100644 index 00000000..dac1295d --- /dev/null +++ b/MyMoviesTests/Mocks/Movie/movie-success.json @@ -0,0 +1,75 @@ +{ + "adult": false, + "backdrop_path": "/tmU7GeKVybMWFButWEGl2M4GeiP.jpg", + "belongs_to_collection": { + "id": 230, + "name": "The Godfather Collection", + "poster_path": "/9Baumh5J9N1nJUYzNkm0xsgjpwY.jpg", + "backdrop_path": "/3WZTxpgscsmoUk81TuECXdFOD0R.jpg" + }, + "budget": 6000000, + "genres": [ + { + "id": 18, + "name": "Drama" + }, + { + "id": 80, + "name": "Crime" + } + ], + "homepage": "http://www.thegodfather.com/", + "id": 238, + "imdb_id": "tt0068646", + "original_language": "en", + "original_title": "The Godfather", + "overview": "Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son, Michael steps in to take care of the would-be killers, launching a campaign of bloody revenge.", + "popularity": 107.877, + "poster_path": "/3bhkrj58Vtu7enYsRolD1fZdja1.jpg", + "production_companies": [ + { + "id": 4, + "logo_path": "/gz66EfNoYPqHTYI4q9UEN4CbHRc.png", + "name": "Paramount", + "origin_country": "US" + }, + { + "id": 10211, + "logo_path": null, + "name": "Alfran Productions", + "origin_country": "US" + } + ], + "production_countries": [ + { + "iso_3166_1": "US", + "name": "United States of America" + } + ], + "release_date": "1972-03-14", + "revenue": 245066411, + "runtime": 175, + "spoken_languages": [ + { + "english_name": "English", + "iso_639_1": "en", + "name": "English" + }, + { + "english_name": "Italian", + "iso_639_1": "it", + "name": "Italiano" + }, + { + "english_name": "Latin", + "iso_639_1": "la", + "name": "Latin" + } + ], + "status": "Released", + "tagline": "An offer you can't refuse.", + "title": "The Godfather", + "video": false, + "vote_average": 8.713, + "vote_count": 17270 +} \ No newline at end of file diff --git a/MyMoviesTests/Mocks/TopRated/top-rated-error.json b/MyMoviesTests/Mocks/TopRated/top-rated-error.json new file mode 100644 index 00000000..22c9812f --- /dev/null +++ b/MyMoviesTests/Mocks/TopRated/top-rated-error.json @@ -0,0 +1,5 @@ +{ + "success": false, + "status_code": 34, + "status_message": "The resource you requested could not be found." +} \ No newline at end of file diff --git a/MyMoviesTests/Mocks/TopRated/top-rated-success.json b/MyMoviesTests/Mocks/TopRated/top-rated-success.json new file mode 100644 index 00000000..c8966f8d --- /dev/null +++ b/MyMoviesTests/Mocks/TopRated/top-rated-success.json @@ -0,0 +1,397 @@ +{ + "page": 1, + "results": [ + { + "adult": false, + "backdrop_path": "/tmU7GeKVybMWFButWEGl2M4GeiP.jpg", + "genre_ids": [ + 18, + 80 + ], + "id": 238, + "original_language": "en", + "original_title": "The Godfather", + "overview": "Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son, Michael steps in to take care of the would-be killers, launching a campaign of bloody revenge.", + "popularity": 104.512, + "poster_path": "/3bhkrj58Vtu7enYsRolD1fZdja1.jpg", + "release_date": "1972-03-14", + "title": "The Godfather", + "video": false, + "vote_average": 8.7, + "vote_count": 17262 + }, + { + "adult": false, + "backdrop_path": "/wPU78OPN4BYEgWYdXyg0phMee64.jpg", + "genre_ids": [ + 18, + 80 + ], + "id": 278, + "original_language": "en", + "original_title": "The Shawshank Redemption", + "overview": "Framed in the 1940s for the double murder of his wife and her lover, upstanding banker Andy Dufresne begins a new life at the Shawshank prison, where he puts his accounting skills to work for an amoral warden. During his long stretch in prison, Dufresne comes to be admired by the other inmates -- including an older prisoner named Red -- for his integrity and unquenchable sense of hope.", + "popularity": 89.766, + "poster_path": "/hBcY0fE9pfXzvVaY4GKarweriG2.jpg", + "release_date": "1994-09-23", + "title": "The Shawshank Redemption", + "video": false, + "vote_average": 8.7, + "vote_count": 23060 + }, + { + "adult": false, + "backdrop_path": "/kGzFbGhp99zva6oZODW5atUtnqi.jpg", + "genre_ids": [ + 18, + 80 + ], + "id": 240, + "original_language": "en", + "original_title": "The Godfather Part II", + "overview": "In the continuing saga of the Corleone crime family, a young Vito Corleone grows up in Sicily and in 1910s New York. In the 1950s, Michael Corleone attempts to expand the family business into Las Vegas, Hollywood and Cuba.", + "popularity": 55.967, + "poster_path": "/hek3koDUyRQk7FIhPXsa6mT2Zc3.jpg", + "release_date": "1974-12-20", + "title": "The Godfather Part II", + "video": false, + "vote_average": 8.6, + "vote_count": 10450 + }, + { + "adult": false, + "backdrop_path": "/r9PkFnRUIthgBp2JZZzD380MWZy.jpg", + "genre_ids": [ + 16, + 28, + 12, + 35, + 10751, + 14 + ], + "id": 315162, + "original_language": "en", + "original_title": "Puss in Boots: The Last Wish", + "overview": "Puss in Boots discovers that his passion for adventure has taken its toll: He has burned through eight of his nine lives, leaving him with only one life left. Puss sets out on an epic journey to find the mythical Last Wish and restore his nine lives.", + "popularity": 10563.563, + "poster_path": "/1NqwE6LP9IEdOZ57NCT51ftHtWT.jpg", + "release_date": "2022-12-21", + "title": "Puss in Boots: The Last Wish", + "video": false, + "vote_average": 8.6, + "vote_count": 1067 + }, + { + "adult": false, + "backdrop_path": "/zb6fM1CX41D9rF9hdgclu0peUmy.jpg", + "genre_ids": [ + 18, + 36, + 10752 + ], + "id": 424, + "original_language": "en", + "original_title": "Schindler's List", + "overview": "The true story of how businessman Oskar Schindler saved over a thousand Jewish lives from the Nazis while they worked as slaves in his factory during World War II.", + "popularity": 50.3, + "poster_path": "/sF1U4EUQS8YHUYjNl3pMGNIQyr0.jpg", + "release_date": "1993-12-15", + "title": "Schindler's List", + "video": false, + "vote_average": 8.6, + "vote_count": 13646 + }, + { + "adult": false, + "backdrop_path": "/vI3aUGTuRRdM7J78KIdW98LdxE5.jpg", + "genre_ids": [ + 35, + 18, + 10749 + ], + "id": 19404, + "original_language": "hi", + "original_title": "दिलवाले दुल्हनिया ले जायेंगे", + "overview": "Raj is a rich, carefree, happy-go-lucky second generation NRI. Simran is the daughter of Chaudhary Baldev Singh, who in spite of being an NRI is very strict about adherence to Indian values. Simran has left for India to be married to her childhood fiancé. Raj leaves for India with a mission at his hands, to claim his lady love under the noses of her whole family. Thus begins a saga.", + "popularity": 31.212, + "poster_path": "/2CAL2433ZeIihfX1Hb2139CX0pW.jpg", + "release_date": "1995-10-19", + "title": "Dilwale Dulhania Le Jayenge", + "video": false, + "vote_average": 8.6, + "vote_count": 4019 + }, + { + "adult": false, + "backdrop_path": "/Ab8mkHmkYADjU7wQiOkia9BzGvS.jpg", + "genre_ids": [ + 16, + 10751, + 14 + ], + "id": 129, + "original_language": "ja", + "original_title": "千と千尋の神隠し", + "overview": "A young girl, Chihiro, becomes trapped in a strange new world of spirits. When her parents undergo a mysterious transformation, she must call upon the courage she never knew she had to free her family.", + "popularity": 75.192, + "poster_path": "/39wmItIWsg5sZMyRUHLkWBcuVCM.jpg", + "release_date": "2001-07-20", + "title": "Spirited Away", + "video": false, + "vote_average": 8.5, + "vote_count": 13793 + }, + { + "adult": false, + "backdrop_path": "/qqHQsStV6exghCM7zbObuYBiYxw.jpg", + "genre_ids": [ + 18 + ], + "id": 389, + "original_language": "en", + "original_title": "12 Angry Men", + "overview": "The defense and the prosecution have rested and the jury is filing into the jury room to decide if a young Spanish-American is guilty or innocent of murdering his father. What begins as an open and shut case soon becomes a mini-drama of each of the jurors' prejudices and preconceptions about the trial, the accused, and each other.", + "popularity": 30.865, + "poster_path": "/ppd84D2i9W8jXmsyInGyihiSyqz.jpg", + "release_date": "1957-04-10", + "title": "12 Angry Men", + "video": false, + "vote_average": 8.5, + "vote_count": 6949 + }, + { + "adult": false, + "backdrop_path": "/dIWwZW7dJJtqC6CgWzYkNVKIUm8.jpg", + "genre_ids": [ + 10749, + 16, + 18 + ], + "id": 372058, + "original_language": "ja", + "original_title": "君の名は。", + "overview": "High schoolers Mitsuha and Taki are complete strangers living separate lives. But one night, they suddenly switch places. Mitsuha wakes up in Taki’s body, and he in hers. This bizarre occurrence continues to happen randomly, and the two must adjust their lives around each other.", + "popularity": 105.614, + "poster_path": "/q719jXXEzOoYaps6babgKnONONX.jpg", + "release_date": "2016-08-26", + "title": "Your Name.", + "video": false, + "vote_average": 8.5, + "vote_count": 9397 + }, + { + "adult": false, + "backdrop_path": "/hiKmpZMGZsrkA3cdce8a7Dpos1j.jpg", + "genre_ids": [ + 35, + 53, + 18 + ], + "id": 496243, + "original_language": "ko", + "original_title": "기생충", + "overview": "All unemployed, Ki-taek's family takes peculiar interest in the wealthy and glamorous Parks for their livelihood until they get entangled in an unexpected incident.", + "popularity": 64.296, + "poster_path": "/7IiTTgloJzvGI1TAYymCfbfl3vT.jpg", + "release_date": "2019-05-30", + "title": "Parasite", + "video": false, + "vote_average": 8.5, + "vote_count": 15043 + }, + { + "adult": false, + "backdrop_path": "/3RMLbSEXOn1CzLoNT7xFeLfdxhq.jpg", + "genre_ids": [ + 10749, + 16 + ], + "id": 372754, + "original_language": "ja", + "original_title": "同級生", + "overview": "Rihito Sajo, an honor student with a perfect score on the entrance exam and Hikaru Kusakabe, in a band and popular among girls, would have never crossed paths. Until one day they started talking at the practice for their school’s upcoming chorus festival. After school, the two meet regularly, as Hikaru helps Rihito to improve his singing skills. While they listen to each other’s voice and harmonize, their hearts start to beat together.", + "popularity": 11.746, + "poster_path": "/cIfRCA5wEvj9tApca4UDUagQEiM.jpg", + "release_date": "2016-02-20", + "title": "Dou kyu sei – Classmates", + "video": false, + "vote_average": 8.5, + "vote_count": 284 + }, + { + "adult": false, + "backdrop_path": "/l6hQWH9eDksNJNiXWYRkWqikOdu.jpg", + "genre_ids": [ + 14, + 18, + 80 + ], + "id": 497, + "original_language": "en", + "original_title": "The Green Mile", + "overview": "A supernatural tale set on death row in a Southern prison, where gentle giant John Coffey possesses the mysterious power to heal people's ailments. When the cell block's head guard, Paul Edgecomb, recognizes Coffey's miraculous gift, he tries desperately to help stave off the condemned man's execution.", + "popularity": 116.567, + "poster_path": "/velWPhVMQeQKcxggNEU8YmIo52R.jpg", + "release_date": "1999-12-10", + "title": "The Green Mile", + "video": false, + "vote_average": 8.5, + "vote_count": 14848 + }, + { + "adult": false, + "backdrop_path": "/pbEkjhdfP7yuDcMB78YEZwgD4IN.jpg", + "genre_ids": [ + 18, + 28, + 80, + 53 + ], + "id": 155, + "original_language": "en", + "original_title": "The Dark Knight", + "overview": "Batman raises the stakes in his war on crime. With the help of Lt. Jim Gordon and District Attorney Harvey Dent, Batman sets out to dismantle the remaining criminal organizations that plague the streets. The partnership proves to be effective, but they soon find themselves prey to a reign of chaos unleashed by a rising criminal mastermind known to the terrified citizens of Gotham as the Joker.", + "popularity": 107.305, + "poster_path": "/qJ2tW6WMUDux911r6m7haRef0WH.jpg", + "release_date": "2008-07-14", + "title": "The Dark Knight", + "video": false, + "vote_average": 8.5, + "vote_count": 28945 + }, + { + "adult": false, + "backdrop_path": "/suaEOtk1N1sgg2MTM7oZd2cfVp3.jpg", + "genre_ids": [ + 53, + 80 + ], + "id": 680, + "original_language": "en", + "original_title": "Pulp Fiction", + "overview": "A burger-loving hit man, his philosophical partner, a drug-addled gangster's moll and a washed-up boxer converge in this sprawling, comedic crime caper. Their adventures unfurl in three stories that ingeniously trip back and forth in time.", + "popularity": 66.873, + "poster_path": "/fIE3lAGcZDV1G6XM5KmuWnNsPp1.jpg", + "release_date": "1994-09-10", + "title": "Pulp Fiction", + "video": false, + "vote_average": 8.5, + "vote_count": 24419 + }, + { + "adult": false, + "backdrop_path": "/eoCSp75lxatmIa6aGqfnzwtbttd.jpg", + "genre_ids": [ + 37 + ], + "id": 429, + "original_language": "it", + "original_title": "Il buono, il brutto, il cattivo", + "overview": "While the Civil War rages on between the Union and the Confederacy, three men – a quiet loner, a ruthless hitman, and a Mexican bandit – comb the American Southwest in search of a strongbox containing $200,000 in stolen gold.", + "popularity": 49.075, + "poster_path": "/bX2xnavhMYjWDoZp1VM6VnU1xwe.jpg", + "release_date": "1966-12-23", + "title": "The Good, the Bad and the Ugly", + "video": false, + "vote_average": 8.5, + "vote_count": 7119 + }, + { + "adult": false, + "backdrop_path": "/w2uGvCpMtvRqZg6waC1hvLyZoJa.jpg", + "genre_ids": [ + 10749 + ], + "id": 696374, + "original_language": "en", + "original_title": "Gabriel's Inferno", + "overview": "An intriguing and sinful exploration of seduction, forbidden love, and redemption, Gabriel's Inferno is a captivating and wildly passionate tale of one man's escape from his own personal hell as he tries to earn the impossible--forgiveness and love.", + "popularity": 11.33, + "poster_path": "/oyG9TL7FcRP4EZ9Vid6uKzwdndz.jpg", + "release_date": "2020-05-29", + "title": "Gabriel's Inferno", + "video": false, + "vote_average": 8.5, + "vote_count": 2315 + }, + { + "adult": false, + "backdrop_path": "/3h1JZGDhZ8nzxdgvkxha0qBqi05.jpg", + "genre_ids": [ + 35, + 18, + 10749 + ], + "id": 13, + "original_language": "en", + "original_title": "Forrest Gump", + "overview": "A man with a low IQ has accomplished great things in his life and been present during significant historic events—in each case, far exceeding what anyone imagined he could do. But despite all he has achieved, his one true love eludes him.", + "popularity": 112.207, + "poster_path": "/arw2vcBveWOVZr6pxd9XTd1TdQa.jpg", + "release_date": "1994-06-23", + "title": "Forrest Gump", + "video": false, + "vote_average": 8.5, + "vote_count": 23926 + }, + { + "adult": false, + "backdrop_path": "/lXhgCODAbBXL5buk9yEmTpOoOgR.jpg", + "genre_ids": [ + 12, + 14, + 28 + ], + "id": 122, + "original_language": "en", + "original_title": "The Lord of the Rings: The Return of the King", + "overview": "Aragorn is revealed as the heir to the ancient kings as he, Gandalf and the other members of the broken fellowship struggle to save Gondor from Sauron's forces. Meanwhile, Frodo and Sam take the ring closer to the heart of Mordor, the dark lord's realm.", + "popularity": 86.447, + "poster_path": "/rCzpDGLbOoPwLjy3OAm5NUPOTrC.jpg", + "release_date": "2003-12-01", + "title": "The Lord of the Rings: The Return of the King", + "video": false, + "vote_average": 8.5, + "vote_count": 20900 + }, + { + "adult": false, + "backdrop_path": "/sw7mordbZxgITU877yTpZCud90M.jpg", + "genre_ids": [ + 18, + 80 + ], + "id": 769, + "original_language": "en", + "original_title": "GoodFellas", + "overview": "The true story of Henry Hill, a half-Irish, half-Sicilian Brooklyn kid who is adopted by neighbourhood gangsters at an early age and climbs the ranks of a Mafia family under the guidance of Jimmy Conway.", + "popularity": 51.359, + "poster_path": "/aKuFiU82s5ISJpGZp7YkIr3kCUd.jpg", + "release_date": "1990-09-12", + "title": "GoodFellas", + "video": false, + "vote_average": 8.5, + "vote_count": 10863 + }, + { + "adult": false, + "backdrop_path": "/jtAI6OJIWLWiRItNSZoWjrsUtmi.jpg", + "genre_ids": [ + 10749 + ], + "id": 724089, + "original_language": "en", + "original_title": "Gabriel's Inferno: Part II", + "overview": "Professor Gabriel Emerson finally learns the truth about Julia Mitchell's identity, but his realization comes a moment too late. Julia is done waiting for the well-respected Dante specialist to remember her and wants nothing more to do with him. Can Gabriel win back her heart before she finds love in another's arms?", + "popularity": 11.951, + "poster_path": "/x5o8cLZfEXMoZczTYWLrUo1P7UJ.jpg", + "release_date": "2020-07-31", + "title": "Gabriel's Inferno: Part II", + "video": false, + "vote_average": 8.5, + "vote_count": 1455 + } + ], + "total_pages": 535, + "total_results": 10687 +} \ No newline at end of file diff --git a/MyMoviesTests/Mocks/Trending/trending-error.json b/MyMoviesTests/Mocks/Trending/trending-error.json new file mode 100644 index 00000000..827cc820 --- /dev/null +++ b/MyMoviesTests/Mocks/Trending/trending-error.json @@ -0,0 +1,5 @@ +{ + "success": false, + "status_code": 5, + "status_message": "Invalid parameters: Your request parameters are incorrect." +} \ No newline at end of file diff --git a/MyMoviesTests/Mocks/Trending/trending-success.json b/MyMoviesTests/Mocks/Trending/trending-success.json new file mode 100644 index 00000000..74f26df5 --- /dev/null +++ b/MyMoviesTests/Mocks/Trending/trending-success.json @@ -0,0 +1,423 @@ +{ + "page": 1, + "results": [ + { + "adult": false, + "backdrop_path": "/r9PkFnRUIthgBp2JZZzD380MWZy.jpg", + "id": 315162, + "title": "Puss in Boots: The Last Wish", + "original_language": "en", + "original_title": "Puss in Boots: The Last Wish", + "overview": "Puss in Boots discovers that his passion for adventure has taken its toll: He has burned through eight of his nine lives, leaving him with only one life left. Puss sets out on an epic journey to find the mythical Last Wish and restore his nine lives.", + "poster_path": "/1NqwE6LP9IEdOZ57NCT51ftHtWT.jpg", + "media_type": "movie", + "genre_ids": [ + 16, + 28, + 12, + 35, + 10751, + 14 + ], + "popularity": 9724.995, + "release_date": "2022-12-21", + "video": false, + "vote_average": 8.6, + "vote_count": 1400 + }, + { + "adult": false, + "backdrop_path": "/ng9a2oVoZuohU0Mzls9GAVH0MCn.jpg", + "id": 858408, + "title": "Dog Gone", + "original_language": "en", + "original_title": "Dog Gone", + "overview": "When his beloved dog goes missing, a young man embarks on an incredible search with his parents to find him and give him life-saving medication.", + "poster_path": "/eYWdLZCS9W1Xli9bynzFSddLx02.jpg", + "media_type": "movie", + "genre_ids": [ + 10751, + 12, + 18 + ], + "popularity": 51.754, + "release_date": "2023-01-13", + "video": false, + "vote_average": 6.7, + "vote_count": 6 + }, + { + "adult": false, + "backdrop_path": "/s16H6tpK2utvwDtzZ8Qy4qm5Emw.jpg", + "id": 76600, + "title": "Avatar: The Way of Water", + "original_language": "en", + "original_title": "Avatar: The Way of Water", + "overview": "Set more than a decade after the events of the first film, learn the story of the Sully family (Jake, Neytiri, and their kids), the trouble that follows them, the lengths they go to keep each other safe, the battles they fight to stay alive, and the tragedies they endure.", + "poster_path": "/t6HIqrRAclMCA60NsSmeqe9RmNV.jpg", + "media_type": "movie", + "genre_ids": [ + 878, + 12, + 28 + ], + "popularity": 3003.545, + "release_date": "2022-12-16", + "video": false, + "vote_average": 7.7, + "vote_count": 4094 + }, + { + "adult": false, + "backdrop_path": "/lacnOoTHyS8g0eSu7cyQ8TgdGtv.jpg", + "id": 872954, + "title": "The Old Way", + "original_language": "en", + "original_title": "The Old Way", + "overview": "An old gunslinger and his daughter must face the consequences of his past, when the son of a man he murdered years ago arrives to take his revenge.", + "poster_path": "/8HCCYAIocXxMKn7J9yQfDX1vBM5.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 37 + ], + "popularity": 24.751, + "release_date": "2023-01-06", + "video": false, + "vote_average": 5.375, + "vote_count": 8 + }, + { + "adult": false, + "backdrop_path": "/5kAGbi9MFAobQTVfK4kWPnIfnP0.jpg", + "id": 536554, + "title": "M3GAN", + "original_language": "en", + "original_title": "M3GAN", + "overview": "A brilliant toy company roboticist uses artificial intelligence to develop M3GAN, a life-like doll programmed to emotionally bond with her newly orphaned niece. But when the doll's programming works too well, she becomes overprotective of her new friend with terrifying results.", + "poster_path": "/7CNCv9uhqdwK7Fv4bR4nmDysnd9.jpg", + "media_type": "movie", + "genre_ids": [ + 878, + 27, + 35 + ], + "popularity": 2067, + "release_date": "2023-01-06", + "video": false, + "vote_average": 7.208, + "vote_count": 209 + }, + { + "adult": false, + "backdrop_path": "/b08BDQPq42AoLMfhi7DtTOoYqVu.jpg", + "id": 800815, + "title": "The Pale Blue Eye", + "original_language": "en", + "original_title": "The Pale Blue Eye", + "overview": "West Point, New York, 1830. When a cadet at the burgeoning military academy is found hanged with his heart cut out, the top brass summons former New York City constable Augustus Landor to investigate. While attempting to solve this grisly mystery, the reluctant detective engages the help of one of the cadets: a strange but brilliant young fellow by the name of Edgar Allan Poe", + "poster_path": "/9xkGlFRqrN8btTLU0KQvOfn2PHr.jpg", + "media_type": "movie", + "genre_ids": [ + 9648, + 53, + 27, + 80 + ], + "popularity": 637.882, + "release_date": "2022-12-23", + "video": false, + "vote_average": 7.138, + "vote_count": 508 + }, + { + "adult": false, + "backdrop_path": "/mSyQoValhBsJdq3JNGXJww2Q5yL.jpg", + "id": 593643, + "title": "The Menu", + "original_language": "en", + "original_title": "The Menu", + "overview": "A young couple travels to a remote island to eat at an exclusive restaurant where the chef has prepared a lavish menu, with some shocking surprises.", + "poster_path": "/fPtUgMcLIboqlTlPrq0bQpKK8eq.jpg", + "media_type": "movie", + "genre_ids": [ + 35, + 27, + 53 + ], + "popularity": 500.984, + "release_date": "2022-11-18", + "video": false, + "vote_average": 7.306, + "vote_count": 1335 + }, + { + "adult": false, + "backdrop_path": "/e1cC9muSRtAHVtF5GJtKAfATYIT.jpg", + "id": 1063242, + "title": "Disconnect: The Wedding Planner", + "original_language": "fr", + "original_title": "Disconnect: The Wedding Planner", + "overview": "After falling victim to a scam, a desperate man races the clock as he attempts to plan a luxurious destination wedding for an important investor.", + "poster_path": "/tGmCxGkVMOqig2TrbXAsE9dOVvX.jpg", + "media_type": "movie", + "genre_ids": [ + 10749, + 35 + ], + "popularity": 40.16, + "release_date": "2023-01-13", + "video": false, + "vote_average": 2, + "vote_count": 1 + }, + { + "adult": false, + "backdrop_path": "/bTqmsKOr9cSdYptFhbFEOmP3qCm.jpg", + "id": 640146, + "title": "Ant-Man and the Wasp: Quantumania", + "original_language": "en", + "original_title": "Ant-Man and the Wasp: Quantumania", + "overview": "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange new creatures and embarking on an adventure that will push them beyond the limits of what they thought possible.", + "poster_path": "/ynWwsafy7ib7hitxImQ8Lso3lvI.jpg", + "media_type": "movie", + "genre_ids": [ + 12, + 878, + 35 + ], + "popularity": 156.416, + "release_date": "2023-02-17", + "video": false, + "vote_average": 0, + "vote_count": 0 + }, + { + "adult": false, + "backdrop_path": "/5pMy5LF2JAleBNBtuzizfCMWM7k.jpg", + "id": 653851, + "title": "Devotion", + "original_language": "en", + "original_title": "Devotion", + "overview": "The harrowing true story of two elite US Navy fighter pilots during the Korean War. Their heroic sacrifices would ultimately make them the Navy's most celebrated wingmen.", + "poster_path": "/26yQPXymbWeCLKwcmyL8dRjAzth.jpg", + "media_type": "movie", + "genre_ids": [ + 10752, + 36, + 18 + ], + "popularity": 207.761, + "release_date": "2022-11-23", + "video": false, + "vote_average": 6.653, + "vote_count": 49 + }, + { + "adult": false, + "backdrop_path": "/aROq5hpE1WgDMjwIxxmk42kFFQZ.jpg", + "id": 829410, + "title": "Sick", + "original_language": "en", + "original_title": "Sick", + "overview": "In the midst of the pandemic, college student Parker and her best friend Miri decide to self-quarantine at her family's lake house, where they will be alone — or so they think...", + "poster_path": "/yisnA4KdZOfJeDve3DekgUpeCe9.jpg", + "media_type": "movie", + "genre_ids": [ + 27, + 53 + ], + "popularity": 15.085, + "release_date": "2023-01-13", + "video": false, + "vote_average": 6.4, + "vote_count": 9 + }, + { + "adult": false, + "backdrop_path": "/dKqa850uvbNSCaQCV4Im1XlzEtQ.jpg", + "id": 661374, + "title": "Glass Onion: A Knives Out Mystery", + "original_language": "en", + "original_title": "Glass Onion: A Knives Out Mystery", + "overview": "World-famous detective Benoit Blanc heads to Greece to peel back the layers of a mystery surrounding a tech billionaire and his eclectic crew of friends.", + "poster_path": "/vDGr1YdrlfbU9wxTOdpf3zChmv9.jpg", + "media_type": "movie", + "genre_ids": [ + 9648, + 53, + 35 + ], + "popularity": 1656.736, + "release_date": "2022-11-23", + "video": false, + "vote_average": 7.072, + "vote_count": 2576 + }, + { + "adult": false, + "backdrop_path": "/tt79dbOPd9Z9ykEOpvckttgYXwH.jpg", + "id": 545611, + "title": "Everything Everywhere All at Once", + "original_language": "en", + "original_title": "Everything Everywhere All at Once", + "overview": "An aging Chinese immigrant is swept up in an insane adventure, where she alone can save what's important to her by connecting with the lives she could have led in other universes.", + "poster_path": "/w3LxiVYdWWRvEVdn5RYq6jIqkb1.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 12, + 878 + ], + "popularity": 224.437, + "release_date": "2022-03-24", + "video": false, + "vote_average": 8.067, + "vote_count": 2648 + }, + { + "adult": false, + "backdrop_path": "/bQXAqRx2Fgc46uCVWgoPz5L5Dtr.jpg", + "id": 436270, + "title": "Black Adam", + "original_language": "en", + "original_title": "Black Adam", + "overview": "Nearly 5,000 years after he was bestowed with the almighty powers of the Egyptian gods—and imprisoned just as quickly—Black Adam is freed from his earthly tomb, ready to unleash his unique form of justice on the modern world.", + "poster_path": "/pFlaoHTZeyNkG83vxsAJiGzfSsa.jpg", + "media_type": "movie", + "genre_ids": [ + 14, + 28, + 878 + ], + "popularity": 1876.042, + "release_date": "2022-10-21", + "video": false, + "vote_average": 7.218, + "vote_count": 3656 + }, + { + "adult": false, + "backdrop_path": "/iBEs4PnQLkL4BbsAGvsuBgeTCM3.jpg", + "id": 960519, + "title": "Noise", + "original_language": "es", + "original_title": "Ruido", + "overview": "Julia is a mother, or rather, one of many mothers, sisters, daughters, colleagues, who have had their lives torn apart by the widespread violence in a country waging a war against its women. Julia is searching for Ger, her daughter. And in her search, she will weave through the stories and struggles of the different women she will meet.", + "poster_path": "/6GemI78Ge0ABs5qM6CnvCPduKEU.jpg", + "media_type": "movie", + "genre_ids": [ + 18 + ], + "popularity": 122.966, + "release_date": "2022-10-13", + "video": false, + "vote_average": 7.429, + "vote_count": 7 + }, + { + "adult": false, + "backdrop_path": "/8I37NtDffNV7AZlDa7uDvvqhovU.jpg", + "id": 19995, + "title": "Avatar", + "original_language": "en", + "original_title": "Avatar", + "overview": "In the 22nd century, a paraplegic Marine is dispatched to the moon Pandora on a unique mission, but becomes torn between following orders and protecting an alien civilization.", + "poster_path": "/jRXYjXNq0Cs2TcJjLkki24MLp7u.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 12, + 14, + 878 + ], + "popularity": 1552.689, + "release_date": "2009-12-15", + "video": false, + "vote_average": 7.561, + "vote_count": 27754 + }, + { + "adult": false, + "backdrop_path": "/AaV1YIdWKnjAIAOe8UUKBFm327v.jpg", + "id": 361743, + "title": "Top Gun: Maverick", + "original_language": "en", + "original_title": "Top Gun: Maverick", + "overview": "After more than thirty years of service as one of the Navy’s top aviators, and dodging the advancement in rank that would ground him, Pete “Maverick” Mitchell finds himself training a detachment of TOP GUN graduates for a specialized mission the likes of which no living pilot has ever seen.", + "poster_path": "/62HCnUTziyWcpDaBO2i1DX17ljH.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 18 + ], + "popularity": 519.672, + "release_date": "2022-05-27", + "video": false, + "vote_average": 8.3, + "vote_count": 5597 + }, + { + "adult": false, + "backdrop_path": "/yYrvN5WFeGYjJnRzhY0QXuo4Isw.jpg", + "id": 505642, + "title": "Black Panther: Wakanda Forever", + "original_language": "en", + "original_title": "Black Panther: Wakanda Forever", + "overview": "Queen Ramonda, Shuri, M’Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T’Challa’s death. As the Wakandans strive to embrace their next chapter, the heroes must band together with the help of War Dog Nakia and Everett Ross and forge a new path for the kingdom of Wakanda.", + "poster_path": "/sv1xJUazXeYqALzczSZ3O6nkH75.jpg", + "media_type": "movie", + "genre_ids": [ + 28, + 12, + 878 + ], + "popularity": 1180.588, + "release_date": "2022-11-11", + "video": false, + "vote_average": 7.47, + "vote_count": 1604 + }, + { + "adult": false, + "backdrop_path": "/9Md4CqzUGDtK5oEkRRvozLkGc9d.jpg", + "id": 674324, + "title": "The Banshees of Inisherin", + "original_language": "en", + "original_title": "The Banshees of Inisherin", + "overview": "Two lifelong friends find themselves at an impasse when one abruptly ends their relationship, with alarming consequences for both of them.", + "poster_path": "/dpeR3ue0Q93Rk8jhE2DzVPMRGIm.jpg", + "media_type": "movie", + "genre_ids": [ + 18, + 35 + ], + "popularity": 203.388, + "release_date": "2022-10-21", + "video": false, + "vote_average": 7.474, + "vote_count": 463 + }, + { + "adult": false, + "backdrop_path": "/6RCf9jzKxyjblYV4CseayK6bcJo.jpg", + "id": 804095, + "title": "The Fabelmans", + "original_language": "en", + "original_title": "The Fabelmans", + "overview": "Growing up in post-World War II era Arizona, young Sammy Fabelman aspires to become a filmmaker as he reaches adolescence, but soon discovers a shattering family secret and explores how the power of films can help him see the truth.", + "poster_path": "/xId9zoiv5WPQOuxqykUDrlpmrUz.jpg", + "media_type": "movie", + "genre_ids": [ + 18 + ], + "popularity": 161.117, + "release_date": "2022-11-11", + "video": false, + "vote_average": 8.018, + "vote_count": 420 + } + ], + "total_pages": 1000, + "total_results": 20000 +} \ No newline at end of file diff --git a/MyMoviesTests/Mocks/TrendingResult/trending-result-nullable.json b/MyMoviesTests/Mocks/TrendingResult/trending-result-nullable.json new file mode 100644 index 00000000..7fe4e6b9 --- /dev/null +++ b/MyMoviesTests/Mocks/TrendingResult/trending-result-nullable.json @@ -0,0 +1,17 @@ +{ + "adult": null, + "backdrop_path": null, + "id": null, + "title": null, + "original_language": null, + "original_title": null, + "overview": null, + "poster_path": null, + "media_type": null, + "genre_ids": null, + "popularity": null, + "release_date": null, + "video": null, + "vote_average": null, + "vote_count": null +} \ No newline at end of file diff --git a/MyMoviesTests/Mocks/TrendingResult/trending-result.json b/MyMoviesTests/Mocks/TrendingResult/trending-result.json new file mode 100644 index 00000000..e8275568 --- /dev/null +++ b/MyMoviesTests/Mocks/TrendingResult/trending-result.json @@ -0,0 +1,24 @@ +{ + "adult": false, + "backdrop_path": "/r9PkFnRUIthgBp2JZZzD380MWZy.jpg", + "id": 315162, + "title": "Puss in Boots: The Last Wish", + "original_language": "en", + "original_title": "Puss in Boots: The Last Wish", + "overview": "Puss in Boots discovers that his passion for adventure has taken its toll: He has burned through eight of his nine lives, leaving him with only one life left. Puss sets out on an epic journey to find the mythical Last Wish and restore his nine lives.", + "poster_path": "/1NqwE6LP9IEdOZ57NCT51ftHtWT.jpg", + "media_type": "movie", + "genre_ids": [ + 16, + 28, + 12, + 35, + 10751, + 14 + ], + "popularity": 9724.995, + "release_date": "2022-12-21", + "video": false, + "vote_average": 8.6, + "vote_count": 1400 +} \ No newline at end of file diff --git a/MyMoviesTests/Model/FavoriteRepositorySpec.swift b/MyMoviesTests/Model/FavoriteRepositorySpec.swift new file mode 100644 index 00000000..fbc1cfd9 --- /dev/null +++ b/MyMoviesTests/Model/FavoriteRepositorySpec.swift @@ -0,0 +1,58 @@ +// +// FavoriteRepositorySpec.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/16/23. +// + +import XCTest +@testable import MyMovies + +final class FavoriteRepositorySpec: XCTestCase { + var favoritesRepository: FavoriteRepository! + var userDefaults: MockUserDefaults! + + override func setUp() { + userDefaults = MockUserDefaults() + favoritesRepository = FavoriteRepository(userDefaults: userDefaults) + } + + func testInsertOrUpdateMovie() { + guard let data = JsonHelper().getMockData(for: "favorite") else { + fatalError("Mock json can not be converted to Data") + } + + let trendings: [TrendingResult] = JsonHelper().getMockJson(for: data) + + trendings.forEach { + let viewModel = TrendingViewModel(trending: $0) + favoritesRepository.insetOrUpdateMovie(for: $0.id!, viewModel) + } + + XCTAssertEqual(userDefaults.persisted.count, 21) + XCTAssertEqual(favoritesRepository.getAllFavorites().count, 20) + + let movieId: Int64 = 1 + let trending = TrendingResult(posterPath: "", id: movieId, originalTitle: "", name: "") + let viewModel = TrendingViewModel(trending: trending) + + favoritesRepository.insetOrUpdateMovie(for: movieId, viewModel) + + XCTAssertEqual(userDefaults.persisted.count, 22) + XCTAssertEqual(favoritesRepository.getAllFavorites().count, 21) + } + + func testDeleteMovie() { + let movieId: Int64 = 1 + let trending = TrendingResult(posterPath: "", id: movieId, originalTitle: "", name: "") + let viewModel = TrendingViewModel(trending: trending) + + favoritesRepository.insetOrUpdateMovie(for: movieId, viewModel) + XCTAssertEqual(userDefaults.persisted.count, 2) + XCTAssertEqual(favoritesRepository.getAllFavorites().count, 1) + + favoritesRepository.deleteMovie(for: movieId) + XCTAssertEqual(userDefaults.persisted.count, 0) + XCTAssertEqual(favoritesRepository.getAllFavorites().count, 0) + } +} diff --git a/MyMoviesTests/Model/MovieViewModelSpec.swift b/MyMoviesTests/Model/MovieViewModelSpec.swift new file mode 100644 index 00000000..12b99916 --- /dev/null +++ b/MyMoviesTests/Model/MovieViewModelSpec.swift @@ -0,0 +1,75 @@ +// +// MovieViewModelSpec.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import XCTest +@testable import MyMovies + +final class MovieViewModelSpec: XCTestCase { + var apiLoader: APILoader! + var expectation: XCTestExpectation! + let movieId: Int64 = 238 + let apiURL = URL(string: APIPath().fetchMovieDetails(for: 238))! + + override func setUp() { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + let urlSession = URLSession.init(configuration: configuration) + + apiLoader = APILoader(apiHandler: MovieAPI(), urlSession: urlSession) + expectation = expectation(description: "Expectation") + } + + func testSuccessCall() { + let data = JsonHelper().getMockData(for: "movie-success") + + MockURLProtocol.requestHandler = { request in + guard let url = request.url, url == self.apiURL else { + fatalError("Trying to test a different URL") + } + + let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, data) + } + + let viewModel = MovieViewModel(apiLoader: self.apiLoader, movieId: movieId) + + viewModel.fetchMovie { viewModel, error in + XCTAssertNotNil(viewModel) + XCTAssertEqual(viewModel?.title, "The Godfather") + XCTAssertEqual(viewModel?.imageUrl, URL(string: "https://image.tmdb.org/t/p/w185/3bhkrj58Vtu7enYsRolD1fZdja1.jpg?api_key=16094d8ca19f9c0407db3d0b5203bd21")) + XCTAssertEqual(viewModel?.genres, ["Drama", "Crime"]) + XCTAssertEqual(viewModel?.overview, "Spanning the years 1945 to 1955, a chronicle of the fictional Italian-American Corleone crime family. When organized crime family patriarch, Vito Corleone barely survives an attempt on his life, his youngest son, Michael steps in to take care of the would-be killers, launching a campaign of bloody revenge.") + XCTAssertEqual(viewModel?.rate, "8.71") + XCTAssertEqual(viewModel?.totalVoteCount, "17270") + XCTAssertEqual(viewModel?.releaseDate, "03/14/1972") + self.expectation.fulfill() + } + wait(for: [expectation], timeout: 100.0) + } + + func testErrorCall() { + let data = JsonHelper().getMockData(for: "movie-error") + + MockURLProtocol.requestHandler = { request in + guard let url = request.url, url == self.apiURL else { + fatalError("Trying to test a different URL") + } + + let response = HTTPURLResponse(url: self.apiURL, statusCode: 400, httpVersion: nil, headerFields: nil)! + return (response, data) + } + + let viewModel = MovieViewModel(apiLoader: self.apiLoader, movieId: movieId) + + viewModel.fetchMovie { viewModel, error in + XCTAssertNotNil(error) + XCTAssertEqual(error?.message, "Error when communicating with API") + self.expectation.fulfill() + } + wait(for: [expectation], timeout: 100.0) + } +} diff --git a/MyMoviesTests/Model/TopRatedListViewModelSpec.swift b/MyMoviesTests/Model/TopRatedListViewModelSpec.swift new file mode 100644 index 00000000..0f5bce19 --- /dev/null +++ b/MyMoviesTests/Model/TopRatedListViewModelSpec.swift @@ -0,0 +1,70 @@ +// +// TopRatedListViewModelSpec.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import XCTest +@testable import MyMovies + +final class TopRatedListViewModelSpec: XCTestCase { + var apiLoader: APILoader! + var expectation: XCTestExpectation! + let apiURL = URL(string: APIPath().fetchTopRated())! + + override func setUp() { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + let urlSession = URLSession.init(configuration: configuration) + + apiLoader = APILoader(apiHandler: TopRatedAPI(), urlSession: urlSession) + expectation = expectation(description: "Expectation") + } + + func testSuccessCall() { + let data = JsonHelper().getMockData(for: "top-rated-success") + + MockURLProtocol.requestHandler = { request in + guard let url = request.url, url == self.apiURL else { + fatalError("Trying to test a different URL") + } + + let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, data) + } + + let viewModel = TopRatedListViewModel(apiLoader: self.apiLoader) + + viewModel.fetchTopRated { viewModel, error in + XCTAssertNotNil(viewModel) + XCTAssertEqual(viewModel?.numberOfRowsInSection, 20) + XCTAssertEqual(viewModel?.titlePage, "Top Rated") + XCTAssertEqual(viewModel?.getTrending(2).title, "The Godfather Part II") + self.expectation.fulfill() + } + wait(for: [expectation], timeout: 100.0) + } + + func testErrorCall() { + let data = JsonHelper().getMockData(for: "top-rated-error") + + MockURLProtocol.requestHandler = { request in + guard let url = request.url, url == self.apiURL else { + fatalError("Trying to test a different URL") + } + + let response = HTTPURLResponse(url: self.apiURL, statusCode: 400, httpVersion: nil, headerFields: nil)! + return (response, data) + } + + let viewModel = TopRatedListViewModel(apiLoader: self.apiLoader) + + viewModel.fetchTopRated { viewModel, error in + XCTAssertNotNil(error) + XCTAssertEqual(error?.message, "Error when communicating with API") + self.expectation.fulfill() + } + wait(for: [expectation], timeout: 100.0) + } +} diff --git a/MyMoviesTests/Model/TrendingListViewModelSpec.swift b/MyMoviesTests/Model/TrendingListViewModelSpec.swift new file mode 100644 index 00000000..a11e451a --- /dev/null +++ b/MyMoviesTests/Model/TrendingListViewModelSpec.swift @@ -0,0 +1,70 @@ +// +// TrendingTests.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import XCTest +@testable import MyMovies + +final class TrendingListViewModelSpec: XCTestCase { + var apiLoader: APILoader! + var expectation: XCTestExpectation! + let apiURL = URL(string: APIPath().fetchTrending(with: TrendingParams(mediaType: .movie, timeWindow: .day)))! + + override func setUp() { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockURLProtocol.self] + let urlSession = URLSession.init(configuration: configuration) + + apiLoader = APILoader(apiHandler: TrendingAPI(), urlSession: urlSession) + expectation = expectation(description: "Expectation") + } + + func testSuccessCall() { + let data = JsonHelper().getMockData(for: "trending-success") + + MockURLProtocol.requestHandler = { request in + guard let url = request.url, url == self.apiURL else { + fatalError("Trying to test a different URL") + } + + let response = HTTPURLResponse(url: self.apiURL, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, data) + } + + let viewModel = TrendingListViewModel(apiLoader: self.apiLoader) + + viewModel.fetchTrendings(of: TrendingParams(mediaType: .movie, timeWindow: .day)) { viewModel, error in + XCTAssertNotNil(viewModel) + XCTAssertEqual(viewModel?.numberOfRowsInSection, 20) + XCTAssertEqual(viewModel?.titlePage, "Trending") + XCTAssertEqual(viewModel?.getTrending(2).title, "Avatar: The Way of Water") + self.expectation.fulfill() + } + wait(for: [expectation], timeout: 100.0) + } + + func testErrorCall() { + let data = JsonHelper().getMockData(for: "trending-error") + + MockURLProtocol.requestHandler = { request in + guard let url = request.url, url == self.apiURL else { + fatalError("Trying to test a different URL") + } + + let response = HTTPURLResponse(url: self.apiURL, statusCode: 400, httpVersion: nil, headerFields: nil)! + return (response, data) + } + + let viewModel = TrendingListViewModel(apiLoader: self.apiLoader) + + viewModel.fetchTrendings(of: TrendingParams(mediaType: .movie, timeWindow: .day)) { viewModel, error in + XCTAssertNotNil(error) + XCTAssertEqual(error?.message, "Error when communicating with API") + self.expectation.fulfill() + } + wait(for: [expectation], timeout: 100.0) + } +} diff --git a/MyMoviesTests/Model/TrendingViewModelSpec.swift b/MyMoviesTests/Model/TrendingViewModelSpec.swift new file mode 100644 index 00000000..a88dc4bf --- /dev/null +++ b/MyMoviesTests/Model/TrendingViewModelSpec.swift @@ -0,0 +1,37 @@ +// +// TrendingViewModel.swift +// MyMoviesTests +// +// Created by Caio dos Santos Ambrosio on 1/14/23. +// + +import XCTest +@testable import MyMovies + +final class TrendingViewModelSpec: XCTestCase { + func testSuccessJson() { + guard let data = JsonHelper().getMockData(for: "trending-result") else { + fatalError("Mock json can not be converted to Data") + } + + let trending: TrendingResult = JsonHelper().getMockJson(for: data) + let viewModel = TrendingViewModel(trending: trending) + + XCTAssertEqual(viewModel.title, trending.originalTitle) + XCTAssertEqual(viewModel.posterImageURL, URL(string: "https://image.tmdb.org/t/p/w154/1NqwE6LP9IEdOZ57NCT51ftHtWT.jpg?api_key=16094d8ca19f9c0407db3d0b5203bd21")) + XCTAssertEqual(viewModel.movieId, 315162) + } + + func testNullableJson() { + guard let data = JsonHelper().getMockData(for: "trending-result-nullable") else { + fatalError("Mock json can not be converted to Data") + } + + let trending: TrendingResult = JsonHelper().getMockJson(for: data) + let viewModel = TrendingViewModel(trending: trending) + + XCTAssertEqual(viewModel.title, "") + XCTAssertEqual(viewModel.posterImageURL, URL(string: "https://image.tmdb.org/t/p/w154?api_key=16094d8ca19f9c0407db3d0b5203bd21")) + XCTAssertEqual(viewModel.movieId, nil) + } +} diff --git a/MyMoviesUITests/MyMoviesUITests.swift b/MyMoviesUITests/MyMoviesUITests.swift new file mode 100644 index 00000000..0b9348f5 --- /dev/null +++ b/MyMoviesUITests/MyMoviesUITests.swift @@ -0,0 +1,24 @@ +// +// MyMoviesUITests.swift +// MyMoviesUITests +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import XCTest + +final class MyMoviesUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } +} diff --git a/MyMoviesUITests/MyMoviesUITestsLaunchTests.swift b/MyMoviesUITests/MyMoviesUITestsLaunchTests.swift new file mode 100644 index 00000000..4754da7e --- /dev/null +++ b/MyMoviesUITests/MyMoviesUITestsLaunchTests.swift @@ -0,0 +1,19 @@ +// +// MyMoviesUITestsLaunchTests.swift +// MyMoviesUITests +// +// Created by Caio dos Santos Ambrosio on 1/10/23. +// + +import XCTest + +final class MyMoviesUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } +}