diff --git a/Movie/.gitattributes b/Movie/.gitattributes new file mode 100755 index 00000000..8edfb9b1 --- /dev/null +++ b/Movie/.gitattributes @@ -0,0 +1 @@ +*.pbxproj binary merge=union diff --git a/Movie/.gitignore b/Movie/.gitignore new file mode 100755 index 00000000..c54f822c --- /dev/null +++ b/Movie/.gitignore @@ -0,0 +1,164 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData/ + +##AppCode +.idea/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +#Firebase +*/GoogleService-Info.plist + +#Android Studio + +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ + +# lint/reports/ diff --git a/Movie/.swiftlint.yml b/Movie/.swiftlint.yml new file mode 100644 index 00000000..9dff5ac1 --- /dev/null +++ b/Movie/.swiftlint.yml @@ -0,0 +1,46 @@ +disabled_rules: + - vertical_parameter_alignment + +opt_in_rules: + - empty_count + +included: + - Movie + +shorthand_operator: warning +# vertical_parameter_alignment: warning +multiple_closures_with_trailing_closure: warning + +function_body_length: + - 100 # warning + - 300 # error + +force_cast: warning +force_try: + severity: warning + +line_length: 300 + +type_body_length: + - 400 # warning + - 1500 # error + +file_length: + warning: 500 + error: 1000 + +type_name: + min_length: 2 # warning + max_length: # error + warning: 50 + error: 60 + +identifier_name: + min_length: 2 + #max_length: # only min_length + # error: 4 # only error + excluded: # excluded via string array + - id + - URL + - GlobalAPIKey +reporter: "xcode" diff --git a/Movie/Movie.xcodeproj/project.pbxproj b/Movie/Movie.xcodeproj/project.pbxproj new file mode 100644 index 00000000..78bb3ffd --- /dev/null +++ b/Movie/Movie.xcodeproj/project.pbxproj @@ -0,0 +1,894 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + objects = { + +/* Begin PBXBuildFile section */ + 0438DCE22613219E0010E33D /* MovieTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0438DCE12613219E0010E33D /* MovieTests.swift */; }; + 0438DCEC261322220010E33D /* ModelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0438DCEB261322220010E33D /* ModelTest.swift */; }; + 043F710C260B9EB4005FA9CB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043F710B260B9EB4005FA9CB /* AppDelegate.swift */; }; + 043F710E260B9EB4005FA9CB /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043F710D260B9EB4005FA9CB /* SceneDelegate.swift */; }; + 043F7110260B9EB4005FA9CB /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043F710F260B9EB4005FA9CB /* ViewController.swift */; }; + 043F7113260B9EB4005FA9CB /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 043F7111260B9EB4005FA9CB /* Main.storyboard */; }; + 043F7115260B9EB7005FA9CB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 043F7114260B9EB7005FA9CB /* Assets.xcassets */; }; + 043F7118260B9EB7005FA9CB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 043F7116260B9EB7005FA9CB /* LaunchScreen.storyboard */; }; + 045D519A260E130F0096814A /* MIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045D5199260E130F0096814A /* MIcons.swift */; }; + 045D519E260E191E0096814A /* UIScrollView+reachedBottom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045D519D260E191E0096814A /* UIScrollView+reachedBottom.swift */; }; + 045D51A8260E24FD0096814A /* MovieCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045D51A6260E24FD0096814A /* MovieCollectionViewCell.swift */; }; + 045D51A9260E24FD0096814A /* MovieCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 045D51A7260E24FD0096814A /* MovieCollectionViewCell.xib */; }; + 045D51AD260E28A20096814A /* NSObject+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045D51AC260E28A10096814A /* NSObject+Ext.swift */; }; + 045D51B2260E2B530096814A /* VerticalFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045D51B1260E2B530096814A /* VerticalFlowLayout.swift */; }; + 045D51BF260E4B6A0096814A /* DetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045D51BE260E4B6A0096814A /* DetailViewController.swift */; }; + 045D51C2260E4B9F0096814A /* DetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045D51C1260E4B9F0096814A /* DetailViewModel.swift */; }; + 0470A2AD261327A6003FDD1F /* MBaseURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F6946F260BA30D00E45B48 /* MBaseURL.swift */; }; + 0470A2B326132A74003FDD1F /* genre.json in Resources */ = {isa = PBXBuildFile; fileRef = 0470A2B026132A74003FDD1F /* genre.json */; }; + 0470A2B426132A74003FDD1F /* trending.json in Resources */ = {isa = PBXBuildFile; fileRef = 0470A2B126132A74003FDD1F /* trending.json */; }; + 0470A2BA26132AE8003FDD1F /* genre.json in Resources */ = {isa = PBXBuildFile; fileRef = 0470A2B026132A74003FDD1F /* genre.json */; }; + 0470A2BD26132AED003FDD1F /* reviews.json in Resources */ = {isa = PBXBuildFile; fileRef = 0470A2B226132A74003FDD1F /* reviews.json */; }; + 0470A2C026132AF2003FDD1F /* trending.json in Resources */ = {isa = PBXBuildFile; fileRef = 0470A2B126132A74003FDD1F /* trending.json */; }; + 0470A2C326132AF5003FDD1F /* reviews.json in Resources */ = {isa = PBXBuildFile; fileRef = 0470A2B226132A74003FDD1F /* reviews.json */; }; + 047BCA8B2613B6F5009CCEBB /* TestsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047BCA8A2613B6F5009CCEBB /* TestsHelper.swift */; }; + 04AC97E7260C899900FDBF81 /* HTTPTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97E1260C899900FDBF81 /* HTTPTask.swift */; }; + 04AC97E8260C899900FDBF81 /* NetworkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97E2260C899900FDBF81 /* NetworkRouter.swift */; }; + 04AC97E9260C899900FDBF81 /* NetworkLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97E3260C899900FDBF81 /* NetworkLogger.swift */; }; + 04AC97EA260C899900FDBF81 /* EndPointType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97E4260C899900FDBF81 /* EndPointType.swift */; }; + 04AC97EB260C899900FDBF81 /* HTTPMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97E5260C899900FDBF81 /* HTTPMethod.swift */; }; + 04AC97EC260C899900FDBF81 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97E6260C899900FDBF81 /* Response.swift */; }; + 04AC97F1260C8A0000FDBF81 /* NetworkEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97F0260C8A0000FDBF81 /* NetworkEnvironment.swift */; }; + 04AC97F7260C8A3A00FDBF81 /* ParameterEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97F3260C8A3900FDBF81 /* ParameterEncoding.swift */; }; + 04AC97F8260C8A3A00FDBF81 /* JSONParameterEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97F4260C8A3900FDBF81 /* JSONParameterEncoder.swift */; }; + 04AC97F9260C8A3A00FDBF81 /* MultiPartParameterEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97F5260C8A3A00FDBF81 /* MultiPartParameterEncoder.swift */; }; + 04AC97FA260C8A3A00FDBF81 /* URLParameterEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97F6260C8A3A00FDBF81 /* URLParameterEncoding.swift */; }; + 04AC97FE260C8B4400FDBF81 /* RxNetworkingWrappers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC97FD260C8B4400FDBF81 /* RxNetworkingWrappers.swift */; }; + 04AC9803260C8B8900FDBF81 /* ReviewEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC9802260C8B8900FDBF81 /* ReviewEndPoint.swift */; }; + 04AC9806260C8BDE00FDBF81 /* Shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC9805260C8BDE00FDBF81 /* Shared.swift */; }; + 04AC9809260C8D5D00FDBF81 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC9808260C8D5D00FDBF81 /* Environment.swift */; }; + 04AC980C260C922500FDBF81 /* ReviewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC980B260C922500FDBF81 /* ReviewAPI.swift */; }; + 04AC980F260CA1DA00FDBF81 /* MovieEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC980E260CA1DA00FDBF81 /* MovieEndPoint.swift */; }; + 04AC9813260CA9BF00FDBF81 /* MovieAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC9812260CA9BF00FDBF81 /* MovieAPI.swift */; }; + 04AC981B260CB08800FDBF81 /* JSONDecoderHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC981A260CB08800FDBF81 /* JSONDecoderHelper.swift */; }; + 04AC9837260CCFB700FDBF81 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC9836260CCFB700FDBF81 /* MainViewModel.swift */; }; + 04AC983B260CD1E600FDBF81 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC983A260CD1E600FDBF81 /* Store.swift */; }; + 04AC9859260CDE2500FDBF81 /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04AC9858260CDE2500FDBF81 /* ActivityIndicator.swift */; }; + 04F69470260BA30D00E45B48 /* MBaseURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F6946F260BA30D00E45B48 /* MBaseURL.swift */; }; + 04F694A7260BB68F00E45B48 /* MoviePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F694A6260BB68F00E45B48 /* MoviePayload.swift */; }; + 04F694AF260BBC7700E45B48 /* ReviewPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F694AE260BBC7700E45B48 /* ReviewPayload.swift */; }; + 04F694B2260BBD4200E45B48 /* GenrePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F694B1260BBD4200E45B48 /* GenrePayload.swift */; }; + 26A0C7798850B4B3B13FCD95 /* Pods_MovieTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D45CE2AF961285A7891DFBDE /* Pods_MovieTests.framework */; }; + 8B0EF26B81A3D1B7FAC51E61 /* Pods_Movie.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78935022E80AADE4B78183F4 /* Pods_Movie.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 0438DCE42613219E0010E33D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 043F7100260B9EB4005FA9CB /* Project object */; + proxyType = 1; + remoteGlobalIDString = 043F7107260B9EB4005FA9CB; + remoteInfo = Movie; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 0438DCDF2613219E0010E33D /* MovieTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MovieTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 0438DCE12613219E0010E33D /* MovieTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieTests.swift; sourceTree = ""; }; + 0438DCE32613219E0010E33D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0438DCEB261322220010E33D /* ModelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTest.swift; sourceTree = ""; }; + 043F7108260B9EB4005FA9CB /* Movie.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Movie.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 043F710B260B9EB4005FA9CB /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 043F710D260B9EB4005FA9CB /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 043F710F260B9EB4005FA9CB /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 043F7112260B9EB4005FA9CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 043F7114260B9EB7005FA9CB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 043F7117260B9EB7005FA9CB /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 043F7119260B9EB7005FA9CB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 045D5199260E130F0096814A /* MIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MIcons.swift; sourceTree = ""; }; + 045D519D260E191E0096814A /* UIScrollView+reachedBottom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIScrollView+reachedBottom.swift"; sourceTree = ""; }; + 045D51A6260E24FD0096814A /* MovieCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieCollectionViewCell.swift; sourceTree = ""; }; + 045D51A7260E24FD0096814A /* MovieCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MovieCollectionViewCell.xib; sourceTree = ""; }; + 045D51AC260E28A10096814A /* NSObject+Ext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSObject+Ext.swift"; sourceTree = ""; }; + 045D51B1260E2B530096814A /* VerticalFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalFlowLayout.swift; sourceTree = ""; }; + 045D51BE260E4B6A0096814A /* DetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewController.swift; sourceTree = ""; }; + 045D51C1260E4B9F0096814A /* DetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewModel.swift; sourceTree = ""; }; + 0470A2B026132A74003FDD1F /* genre.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = genre.json; sourceTree = ""; }; + 0470A2B126132A74003FDD1F /* trending.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = trending.json; sourceTree = ""; }; + 0470A2B226132A74003FDD1F /* reviews.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = reviews.json; sourceTree = ""; }; + 047BCA8A2613B6F5009CCEBB /* TestsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestsHelper.swift; sourceTree = ""; }; + 04AC97E1260C899900FDBF81 /* HTTPTask.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPTask.swift; sourceTree = ""; }; + 04AC97E2260C899900FDBF81 /* NetworkRouter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkRouter.swift; sourceTree = ""; }; + 04AC97E3260C899900FDBF81 /* NetworkLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkLogger.swift; sourceTree = ""; }; + 04AC97E4260C899900FDBF81 /* EndPointType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EndPointType.swift; sourceTree = ""; }; + 04AC97E5260C899900FDBF81 /* HTTPMethod.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTTPMethod.swift; sourceTree = ""; }; + 04AC97E6260C899900FDBF81 /* Response.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = ""; }; + 04AC97F0260C8A0000FDBF81 /* NetworkEnvironment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkEnvironment.swift; sourceTree = ""; }; + 04AC97F3260C8A3900FDBF81 /* ParameterEncoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ParameterEncoding.swift; sourceTree = ""; }; + 04AC97F4260C8A3900FDBF81 /* JSONParameterEncoder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONParameterEncoder.swift; sourceTree = ""; }; + 04AC97F5260C8A3A00FDBF81 /* MultiPartParameterEncoder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MultiPartParameterEncoder.swift; sourceTree = ""; }; + 04AC97F6260C8A3A00FDBF81 /* URLParameterEncoding.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLParameterEncoding.swift; sourceTree = ""; }; + 04AC97FD260C8B4400FDBF81 /* RxNetworkingWrappers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RxNetworkingWrappers.swift; sourceTree = ""; }; + 04AC9802260C8B8900FDBF81 /* ReviewEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewEndPoint.swift; sourceTree = ""; }; + 04AC9805260C8BDE00FDBF81 /* Shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shared.swift; sourceTree = ""; }; + 04AC9808260C8D5D00FDBF81 /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; + 04AC980B260C922500FDBF81 /* ReviewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewAPI.swift; sourceTree = ""; }; + 04AC980E260CA1DA00FDBF81 /* MovieEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieEndPoint.swift; sourceTree = ""; }; + 04AC9812260CA9BF00FDBF81 /* MovieAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieAPI.swift; sourceTree = ""; }; + 04AC981A260CB08800FDBF81 /* JSONDecoderHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONDecoderHelper.swift; sourceTree = ""; }; + 04AC9836260CCFB700FDBF81 /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.swift; sourceTree = ""; }; + 04AC983A260CD1E600FDBF81 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; + 04AC9858260CDE2500FDBF81 /* ActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; + 04F6946F260BA30D00E45B48 /* MBaseURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBaseURL.swift; sourceTree = ""; }; + 04F694A6260BB68F00E45B48 /* MoviePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviePayload.swift; sourceTree = ""; }; + 04F694AE260BBC7700E45B48 /* ReviewPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPayload.swift; sourceTree = ""; }; + 04F694B1260BBD4200E45B48 /* GenrePayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenrePayload.swift; sourceTree = ""; }; + 3BF30F6238E31414DB385DBC /* Pods-MovieTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MovieTests.debug.xcconfig"; path = "Target Support Files/Pods-MovieTests/Pods-MovieTests.debug.xcconfig"; sourceTree = ""; }; + 78935022E80AADE4B78183F4 /* Pods_Movie.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Movie.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AB19EDE628DE8008F42756E7 /* Pods-MovieTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MovieTests.release.xcconfig"; path = "Target Support Files/Pods-MovieTests/Pods-MovieTests.release.xcconfig"; sourceTree = ""; }; + D45CE2AF961285A7891DFBDE /* Pods_MovieTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MovieTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E3E2B0331A9F2914AF3F042A /* Pods-Movie.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Movie.release.xcconfig"; path = "Target Support Files/Pods-Movie/Pods-Movie.release.xcconfig"; sourceTree = ""; }; + E89701B51ABAD91F499C7858 /* Pods-Movie.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Movie.debug.xcconfig"; path = "Target Support Files/Pods-Movie/Pods-Movie.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0438DCDC2613219E0010E33D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 26A0C7798850B4B3B13FCD95 /* Pods_MovieTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 043F7105260B9EB4005FA9CB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B0EF26B81A3D1B7FAC51E61 /* Pods_Movie.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0438DCE02613219E0010E33D /* MovieTests */ = { + isa = PBXGroup; + children = ( + 0470A2B026132A74003FDD1F /* genre.json */, + 0470A2B226132A74003FDD1F /* reviews.json */, + 0470A2B126132A74003FDD1F /* trending.json */, + 0438DCE32613219E0010E33D /* Info.plist */, + 0438DCEB261322220010E33D /* ModelTest.swift */, + 0438DCE12613219E0010E33D /* MovieTests.swift */, + 047BCA8A2613B6F5009CCEBB /* TestsHelper.swift */, + ); + path = MovieTests; + sourceTree = ""; + }; + 043F70FF260B9EB4005FA9CB = { + isa = PBXGroup; + children = ( + 043F710A260B9EB4005FA9CB /* Movie */, + 0438DCE02613219E0010E33D /* MovieTests */, + 043F7109260B9EB4005FA9CB /* Products */, + EB669192B90D57EA4E468615 /* Pods */, + 05FCC35626C5BBAE3EF9B5A3 /* Frameworks */, + ); + sourceTree = ""; + }; + 043F7109260B9EB4005FA9CB /* Products */ = { + isa = PBXGroup; + children = ( + 043F7108260B9EB4005FA9CB /* Movie.app */, + 0438DCDF2613219E0010E33D /* MovieTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 043F710A260B9EB4005FA9CB /* Movie */ = { + isa = PBXGroup; + children = ( + 04F6946C260BA26900E45B48 /* App */, + 043F7111260B9EB4005FA9CB /* Main.storyboard */, + 043F7114260B9EB7005FA9CB /* Assets.xcassets */, + 043F7116260B9EB7005FA9CB /* LaunchScreen.storyboard */, + 043F7119260B9EB7005FA9CB /* Info.plist */, + ); + path = Movie; + sourceTree = ""; + }; + 045D519C260E19080096814A /* Extensions */ = { + isa = PBXGroup; + children = ( + 045D51AC260E28A10096814A /* NSObject+Ext.swift */, + 045D519D260E191E0096814A /* UIScrollView+reachedBottom.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 045D51A5260E241F0096814A /* Cell */ = { + isa = PBXGroup; + children = ( + 045D51A6260E24FD0096814A /* MovieCollectionViewCell.swift */, + 045D51A7260E24FD0096814A /* MovieCollectionViewCell.xib */, + ); + path = Cell; + sourceTree = ""; + }; + 045D51B0260E2B3D0096814A /* Layout */ = { + isa = PBXGroup; + children = ( + 045D51B1260E2B530096814A /* VerticalFlowLayout.swift */, + ); + path = Layout; + sourceTree = ""; + }; + 04AC97CA260C885100FDBF81 /* Networking */ = { + isa = PBXGroup; + children = ( + 04F6946F260BA30D00E45B48 /* MBaseURL.swift */, + 04AC97FD260C8B4400FDBF81 /* RxNetworkingWrappers.swift */, + 04AC97DD260C895B00FDBF81 /* Encoding */, + 04AC97DE260C895F00FDBF81 /* Endpoint */, + 04AC97DF260C896500FDBF81 /* Manager */, + 04AC97E0260C896E00FDBF81 /* NetworkService */, + ); + path = Networking; + sourceTree = ""; + }; + 04AC97DD260C895B00FDBF81 /* Encoding */ = { + isa = PBXGroup; + children = ( + 04AC97F4260C8A3900FDBF81 /* JSONParameterEncoder.swift */, + 04AC97F5260C8A3A00FDBF81 /* MultiPartParameterEncoder.swift */, + 04AC97F3260C8A3900FDBF81 /* ParameterEncoding.swift */, + 04AC97F6260C8A3A00FDBF81 /* URLParameterEncoding.swift */, + ); + path = Encoding; + sourceTree = ""; + }; + 04AC97DE260C895F00FDBF81 /* Endpoint */ = { + isa = PBXGroup; + children = ( + 04AC97F0260C8A0000FDBF81 /* NetworkEnvironment.swift */, + 04AC9801260C8B6000FDBF81 /* Movie */, + 04AC9800260C8B5A00FDBF81 /* Review */, + ); + path = Endpoint; + sourceTree = ""; + }; + 04AC97DF260C896500FDBF81 /* Manager */ = { + isa = PBXGroup; + children = ( + 04AC981A260CB08800FDBF81 /* JSONDecoderHelper.swift */, + 04AC97EE260C89CB00FDBF81 /* Movie */, + 04AC97EF260C89DD00FDBF81 /* Review */, + ); + path = Manager; + sourceTree = ""; + }; + 04AC97E0260C896E00FDBF81 /* NetworkService */ = { + isa = PBXGroup; + children = ( + 04AC97E4260C899900FDBF81 /* EndPointType.swift */, + 04AC97E5260C899900FDBF81 /* HTTPMethod.swift */, + 04AC97E1260C899900FDBF81 /* HTTPTask.swift */, + 04AC97E3260C899900FDBF81 /* NetworkLogger.swift */, + 04AC97E2260C899900FDBF81 /* NetworkRouter.swift */, + 04AC97E6260C899900FDBF81 /* Response.swift */, + ); + path = NetworkService; + sourceTree = ""; + }; + 04AC97EE260C89CB00FDBF81 /* Movie */ = { + isa = PBXGroup; + children = ( + 04AC9812260CA9BF00FDBF81 /* MovieAPI.swift */, + ); + path = Movie; + sourceTree = ""; + }; + 04AC97EF260C89DD00FDBF81 /* Review */ = { + isa = PBXGroup; + children = ( + 04AC980B260C922500FDBF81 /* ReviewAPI.swift */, + ); + path = Review; + sourceTree = ""; + }; + 04AC9800260C8B5A00FDBF81 /* Review */ = { + isa = PBXGroup; + children = ( + 04AC9802260C8B8900FDBF81 /* ReviewEndPoint.swift */, + ); + path = Review; + sourceTree = ""; + }; + 04AC9801260C8B6000FDBF81 /* Movie */ = { + isa = PBXGroup; + children = ( + 04AC980E260CA1DA00FDBF81 /* MovieEndPoint.swift */, + ); + path = Movie; + sourceTree = ""; + }; + 04AC9839260CD1D900FDBF81 /* Shared */ = { + isa = PBXGroup; + children = ( + 045D519C260E19080096814A /* Extensions */, + 04AC9805260C8BDE00FDBF81 /* Shared.swift */, + 04AC983A260CD1E600FDBF81 /* Store.swift */, + 04AC9858260CDE2500FDBF81 /* ActivityIndicator.swift */, + 045D5199260E130F0096814A /* MIcons.swift */, + ); + path = Shared; + sourceTree = ""; + }; + 04F6946C260BA26900E45B48 /* App */ = { + isa = PBXGroup; + children = ( + 043F710B260B9EB4005FA9CB /* AppDelegate.swift */, + 04AC9808260C8D5D00FDBF81 /* Environment.swift */, + 043F710D260B9EB4005FA9CB /* SceneDelegate.swift */, + 04F694A5260BB67E00E45B48 /* Model */, + 04AC97CA260C885100FDBF81 /* Networking */, + 04AC9839260CD1D900FDBF81 /* Shared */, + 04F6946E260BA28000E45B48 /* VC */, + 04F6946D260BA27C00E45B48 /* VM */, + ); + path = App; + sourceTree = ""; + }; + 04F6946D260BA27C00E45B48 /* VM */ = { + isa = PBXGroup; + children = ( + 04AC9836260CCFB700FDBF81 /* MainViewModel.swift */, + 045D51C1260E4B9F0096814A /* DetailViewModel.swift */, + ); + path = VM; + sourceTree = ""; + }; + 04F6946E260BA28000E45B48 /* VC */ = { + isa = PBXGroup; + children = ( + 045D51B0260E2B3D0096814A /* Layout */, + 045D51A5260E241F0096814A /* Cell */, + 043F710F260B9EB4005FA9CB /* ViewController.swift */, + 045D51BE260E4B6A0096814A /* DetailViewController.swift */, + ); + path = VC; + sourceTree = ""; + }; + 04F694A5260BB67E00E45B48 /* Model */ = { + isa = PBXGroup; + children = ( + 04F694A6260BB68F00E45B48 /* MoviePayload.swift */, + 04F694AE260BBC7700E45B48 /* ReviewPayload.swift */, + 04F694B1260BBD4200E45B48 /* GenrePayload.swift */, + ); + path = Model; + sourceTree = ""; + }; + 05FCC35626C5BBAE3EF9B5A3 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 78935022E80AADE4B78183F4 /* Pods_Movie.framework */, + D45CE2AF961285A7891DFBDE /* Pods_MovieTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + EB669192B90D57EA4E468615 /* Pods */ = { + isa = PBXGroup; + children = ( + E89701B51ABAD91F499C7858 /* Pods-Movie.debug.xcconfig */, + E3E2B0331A9F2914AF3F042A /* Pods-Movie.release.xcconfig */, + 3BF30F6238E31414DB385DBC /* Pods-MovieTests.debug.xcconfig */, + AB19EDE628DE8008F42756E7 /* Pods-MovieTests.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0438DCDE2613219E0010E33D /* MovieTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0438DCE82613219E0010E33D /* Build configuration list for PBXNativeTarget "MovieTests" */; + buildPhases = ( + 3C91FD6A869D31480E2E3838 /* [CP] Check Pods Manifest.lock */, + 0438DCDB2613219E0010E33D /* Sources */, + 0438DCDC2613219E0010E33D /* Frameworks */, + 0438DCDD2613219E0010E33D /* Resources */, + ADF38FD0D0CD2C9113FC659A /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 0438DCE52613219E0010E33D /* PBXTargetDependency */, + ); + name = MovieTests; + productName = MovieTests; + productReference = 0438DCDF2613219E0010E33D /* MovieTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 043F7107260B9EB4005FA9CB /* Movie */ = { + isa = PBXNativeTarget; + buildConfigurationList = 043F711C260B9EB7005FA9CB /* Build configuration list for PBXNativeTarget "Movie" */; + buildPhases = ( + D7D9BADF5BB3E90EACFB6C6E /* [CP] Check Pods Manifest.lock */, + 043F7104260B9EB4005FA9CB /* Sources */, + 043F7105260B9EB4005FA9CB /* Frameworks */, + 043F7106260B9EB4005FA9CB /* Resources */, + 76AD461EFBF557F378C9EEFA /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Movie; + productName = Movie; + productReference = 043F7108260B9EB4005FA9CB /* Movie.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 043F7100260B9EB4005FA9CB /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1240; + LastUpgradeCheck = 1240; + TargetAttributes = { + 0438DCDE2613219E0010E33D = { + CreatedOnToolsVersion = 12.4; + TestTargetID = 043F7107260B9EB4005FA9CB; + }; + 043F7107260B9EB4005FA9CB = { + CreatedOnToolsVersion = 12.4; + }; + }; + }; + buildConfigurationList = 043F7103260B9EB4005FA9CB /* Build configuration list for PBXProject "Movie" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 043F70FF260B9EB4005FA9CB; + productRefGroup = 043F7109260B9EB4005FA9CB /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 043F7107260B9EB4005FA9CB /* Movie */, + 0438DCDE2613219E0010E33D /* MovieTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0438DCDD2613219E0010E33D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0470A2C326132AF5003FDD1F /* reviews.json in Resources */, + 0470A2B426132A74003FDD1F /* trending.json in Resources */, + 0470A2B326132A74003FDD1F /* genre.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 043F7106260B9EB4005FA9CB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 043F7118260B9EB7005FA9CB /* LaunchScreen.storyboard in Resources */, + 043F7115260B9EB7005FA9CB /* Assets.xcassets in Resources */, + 0470A2C026132AF2003FDD1F /* trending.json in Resources */, + 0470A2BA26132AE8003FDD1F /* genre.json in Resources */, + 0470A2BD26132AED003FDD1F /* reviews.json in Resources */, + 043F7113260B9EB4005FA9CB /* Main.storyboard in Resources */, + 045D51A9260E24FD0096814A /* MovieCollectionViewCell.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3C91FD6A869D31480E2E3838 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-MovieTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 76AD461EFBF557F378C9EEFA /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Movie/Pods-Movie-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Movie/Pods-Movie-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Movie/Pods-Movie-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + ADF38FD0D0CD2C9113FC659A /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-MovieTests/Pods-MovieTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-MovieTests/Pods-MovieTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MovieTests/Pods-MovieTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + D7D9BADF5BB3E90EACFB6C6E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Movie-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0438DCDB2613219E0010E33D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0438DCE22613219E0010E33D /* MovieTests.swift in Sources */, + 0470A2AD261327A6003FDD1F /* MBaseURL.swift in Sources */, + 0438DCEC261322220010E33D /* ModelTest.swift in Sources */, + 047BCA8B2613B6F5009CCEBB /* TestsHelper.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 043F7104260B9EB4005FA9CB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 04F694B2260BBD4200E45B48 /* GenrePayload.swift in Sources */, + 043F7110260B9EB4005FA9CB /* ViewController.swift in Sources */, + 043F710C260B9EB4005FA9CB /* AppDelegate.swift in Sources */, + 04AC97FE260C8B4400FDBF81 /* RxNetworkingWrappers.swift in Sources */, + 04AC97E7260C899900FDBF81 /* HTTPTask.swift in Sources */, + 04F69470260BA30D00E45B48 /* MBaseURL.swift in Sources */, + 04AC97F9260C8A3A00FDBF81 /* MultiPartParameterEncoder.swift in Sources */, + 045D519A260E130F0096814A /* MIcons.swift in Sources */, + 04AC9837260CCFB700FDBF81 /* MainViewModel.swift in Sources */, + 04AC9803260C8B8900FDBF81 /* ReviewEndPoint.swift in Sources */, + 04F694AF260BBC7700E45B48 /* ReviewPayload.swift in Sources */, + 04AC97EC260C899900FDBF81 /* Response.swift in Sources */, + 04F694A7260BB68F00E45B48 /* MoviePayload.swift in Sources */, + 04AC97FA260C8A3A00FDBF81 /* URLParameterEncoding.swift in Sources */, + 04AC97E8260C899900FDBF81 /* NetworkRouter.swift in Sources */, + 045D51AD260E28A20096814A /* NSObject+Ext.swift in Sources */, + 04AC9813260CA9BF00FDBF81 /* MovieAPI.swift in Sources */, + 04AC9809260C8D5D00FDBF81 /* Environment.swift in Sources */, + 04AC9859260CDE2500FDBF81 /* ActivityIndicator.swift in Sources */, + 04AC97EA260C899900FDBF81 /* EndPointType.swift in Sources */, + 04AC97F7260C8A3A00FDBF81 /* ParameterEncoding.swift in Sources */, + 04AC97EB260C899900FDBF81 /* HTTPMethod.swift in Sources */, + 043F710E260B9EB4005FA9CB /* SceneDelegate.swift in Sources */, + 04AC97F8260C8A3A00FDBF81 /* JSONParameterEncoder.swift in Sources */, + 045D51C2260E4B9F0096814A /* DetailViewModel.swift in Sources */, + 04AC97F1260C8A0000FDBF81 /* NetworkEnvironment.swift in Sources */, + 04AC9806260C8BDE00FDBF81 /* Shared.swift in Sources */, + 045D51BF260E4B6A0096814A /* DetailViewController.swift in Sources */, + 04AC980C260C922500FDBF81 /* ReviewAPI.swift in Sources */, + 04AC981B260CB08800FDBF81 /* JSONDecoderHelper.swift in Sources */, + 04AC97E9260C899900FDBF81 /* NetworkLogger.swift in Sources */, + 04AC983B260CD1E600FDBF81 /* Store.swift in Sources */, + 045D51A8260E24FD0096814A /* MovieCollectionViewCell.swift in Sources */, + 045D519E260E191E0096814A /* UIScrollView+reachedBottom.swift in Sources */, + 045D51B2260E2B530096814A /* VerticalFlowLayout.swift in Sources */, + 04AC980F260CA1DA00FDBF81 /* MovieEndPoint.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 0438DCE52613219E0010E33D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 043F7107260B9EB4005FA9CB /* Movie */; + targetProxy = 0438DCE42613219E0010E33D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 043F7111260B9EB4005FA9CB /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 043F7112260B9EB4005FA9CB /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 043F7116260B9EB7005FA9CB /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 043F7117260B9EB7005FA9CB /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 0438DCE62613219E0010E33D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3BF30F6238E31414DB385DBC /* Pods-MovieTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SZ6SDPY725; + INFOPLIST_FILE = MovieTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "daresay-tech-task.MovieTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Movie.app/Movie"; + }; + name = Debug; + }; + 0438DCE72613219E0010E33D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AB19EDE628DE8008F42756E7 /* Pods-MovieTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SZ6SDPY725; + INFOPLIST_FILE = MovieTests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "daresay-tech-task.MovieTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Movie.app/Movie"; + }; + name = Release; + }; + 043F711A260B9EB7005FA9CB /* 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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 = 14.4; + 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; + }; + 043F711B260B9EB7005FA9CB /* 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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 = 14.4; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 043F711D260B9EB7005FA9CB /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E89701B51ABAD91F499C7858 /* Pods-Movie.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SZ6SDPY725; + INFOPLIST_FILE = Movie/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "daresay-tech-task.Movie"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 043F711E260B9EB7005FA9CB /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E3E2B0331A9F2914AF3F042A /* Pods-Movie.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = SZ6SDPY725; + INFOPLIST_FILE = Movie/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "daresay-tech-task.Movie"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0438DCE82613219E0010E33D /* Build configuration list for PBXNativeTarget "MovieTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0438DCE62613219E0010E33D /* Debug */, + 0438DCE72613219E0010E33D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 043F7103260B9EB4005FA9CB /* Build configuration list for PBXProject "Movie" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 043F711A260B9EB7005FA9CB /* Debug */, + 043F711B260B9EB7005FA9CB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 043F711C260B9EB7005FA9CB /* Build configuration list for PBXNativeTarget "Movie" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 043F711D260B9EB7005FA9CB /* Debug */, + 043F711E260B9EB7005FA9CB /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 043F7100260B9EB4005FA9CB /* Project object */; +} diff --git a/Movie/Movie.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Movie/Movie.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/Movie/Movie.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Movie/Movie.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Movie/Movie.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/Movie/Movie.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Movie/Movie.xcodeproj/xcshareddata/xcschemes/Movie.xcscheme b/Movie/Movie.xcodeproj/xcshareddata/xcschemes/Movie.xcscheme new file mode 100644 index 00000000..523ce922 --- /dev/null +++ b/Movie/Movie.xcodeproj/xcshareddata/xcschemes/Movie.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Movie/Movie.xcodeproj/xcshareddata/xcschemes/MovieTests.xcscheme b/Movie/Movie.xcodeproj/xcshareddata/xcschemes/MovieTests.xcscheme new file mode 100644 index 00000000..3e2ffcab --- /dev/null +++ b/Movie/Movie.xcodeproj/xcshareddata/xcschemes/MovieTests.xcscheme @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Movie/Movie.xcworkspace/contents.xcworkspacedata b/Movie/Movie.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..54c08db2 --- /dev/null +++ b/Movie/Movie.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Movie/Movie.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Movie/Movie.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/Movie/Movie.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Movie/Movie/App/AppDelegate.swift b/Movie/Movie/App/AppDelegate.swift new file mode 100644 index 00000000..2d009857 --- /dev/null +++ b/Movie/Movie/App/AppDelegate.swift @@ -0,0 +1,35 @@ +// +// AppDelegate.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-24. +// + +import UIKit +import RxSwift + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + let disposeBag = DisposeBag() + + 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/Movie/Movie/App/Environment.swift b/Movie/Movie/App/Environment.swift new file mode 100644 index 00000000..a8878b7d --- /dev/null +++ b/Movie/Movie/App/Environment.swift @@ -0,0 +1,24 @@ +// +// Environment.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation + +/* + inspired from pointfree.co + https://vimeo.com/291588126 + */ + +// swiftlint:disable all +var Current = Environment() + +struct Environment { + let token = """ +0e3d23cbe1ef8e612bd89882bbf65290 +""" + let locale = "en-US" +} +// swiftlint:enable all diff --git a/Movie/Movie/App/Model/GenrePayload.swift b/Movie/Movie/App/Model/GenrePayload.swift new file mode 100644 index 00000000..0601422f --- /dev/null +++ b/Movie/Movie/App/Model/GenrePayload.swift @@ -0,0 +1,19 @@ +// +// GenrePayload.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-24. +// + +import Foundation + +// MARK: - GenrePayload +struct GenrePayload: Codable { + let genres: [Genre] +} + +// MARK: - Genre +struct Genre: Codable { + let id: Int + let name: String +} diff --git a/Movie/Movie/App/Model/MoviePayload.swift b/Movie/Movie/App/Model/MoviePayload.swift new file mode 100644 index 00000000..f961ad7c --- /dev/null +++ b/Movie/Movie/App/Model/MoviePayload.swift @@ -0,0 +1,81 @@ +// +// MoviePayload.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-24. +// + +import Foundation +import RxDataSources + +struct MoviePayload: Codable { + let page: Int + let results: [Movie] + let totalPages, totalResults: Int + + enum CodingKeys: String, CodingKey { + case page, results + case totalPages = "total_pages" + case totalResults = "total_results" + } +} + +var mDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + // formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter +}() + +struct Movie: Codable { + let adult: Bool + let backdropPath: String + let genreIDS: [Int] + let id: Int + let originalLanguage, originalTitle, overview: String + let popularity: Double + let posterPath, releaseDate, title: String + let video: Bool + let voteAverage: Double + let voteCount: Int + + enum CodingKeys: String, CodingKey { + case adult + case backdropPath = "backdrop_path" + case genreIDS = "genre_ids" + case id + case originalLanguage = "original_language" + case originalTitle = "original_title" + case overview, popularity + case posterPath = "poster_path" + case releaseDate = "release_date" + case title, video + case voteAverage = "vote_average" + case voteCount = "vote_count" + } +} + +extension Movie { + var backDropUrl: URL { + MBaseURL.imageURL.appendingPathComponent(backdropPath) + } + var posterUrl: URL { + MBaseURL.imageURL.appendingPathComponent(posterPath) + } +} + +extension Movie: Equatable, Comparable { + static func == (lhs: Movie, rhs: Movie) -> Bool { + lhs.id == rhs.id + } + + static func < (lhs: Movie, rhs: Movie) -> Bool { + guard let lhsDate = mDateFormatter.date(from: lhs.releaseDate), + let rhsDate = mDateFormatter.date(from: rhs.releaseDate) else { return false } + return lhsDate < rhsDate + } +} + +extension Movie: IdentifiableType { + var identity: Int { id } +} diff --git a/Movie/Movie/App/Model/ReviewPayload.swift b/Movie/Movie/App/Model/ReviewPayload.swift new file mode 100644 index 00000000..2818de5b --- /dev/null +++ b/Movie/Movie/App/Model/ReviewPayload.swift @@ -0,0 +1,48 @@ +// +// ReviewPayload.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-24. +// + +import Foundation + +struct ReviewPayload: Codable { + let id, page: Int + let results: [Review] + let totalPages, totalResults: Int + + enum CodingKeys: String, CodingKey { + case id, page, results + case totalPages = "total_pages" + case totalResults = "total_results" + } +} + +struct Review: Codable { + let author: String + let authorDetails: AuthorDetails + let content, createdAt, id, updatedAt: String + let url: String + + enum CodingKeys: String, CodingKey { + case author + case authorDetails = "author_details" + case content + case createdAt = "created_at" + case id + case updatedAt = "updated_at" + case url + } +} + +struct AuthorDetails: Codable { + let name, username, avatarPath: String? + let rating: Int? + + enum CodingKeys: String, CodingKey { + case name, username + case avatarPath = "avatar_path" + case rating + } +} diff --git a/Movie/Movie/App/Networking/Encoding/JSONParameterEncoder.swift b/Movie/Movie/App/Networking/Encoding/JSONParameterEncoder.swift new file mode 100644 index 00000000..cd13c983 --- /dev/null +++ b/Movie/Movie/App/Networking/Encoding/JSONParameterEncoder.swift @@ -0,0 +1,23 @@ +// +// JSONParameterEncoder.swift +// +// +// Created by Andrian Sergheev on 2019-02-25. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +import Foundation + +public struct JSONParameterEncoder: ParameterEncoder { + public func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws { + do { + let jsonAsData = try JSONSerialization.data(withJSONObject: parameters, options: [.withoutEscapingSlashes, .prettyPrinted]) + urlRequest.httpBody = jsonAsData + if urlRequest.value(forHTTPHeaderField: "Content-Type") == nil { + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + } catch { + throw NetworkError.encodingFailed + } + } +} diff --git a/Movie/Movie/App/Networking/Encoding/MultiPartParameterEncoder.swift b/Movie/Movie/App/Networking/Encoding/MultiPartParameterEncoder.swift new file mode 100644 index 00000000..f63689f6 --- /dev/null +++ b/Movie/Movie/App/Networking/Encoding/MultiPartParameterEncoder.swift @@ -0,0 +1,70 @@ +// +// MultiPartParameterEncoder.swift +// +// +// Created by Andrian Sergheev on 2019-08-09. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +import Foundation + +public struct MultiPartParameterEncoder: ParameterEncoder { + + public func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws { + + let boundary = "\(UUID().uuidString)" + urlRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + // find the multipart data + let multipartData = parameters.filter { $1 is Data } + // make a copy of the params + var parametersCopy = parameters + // remove the multipart data from the copy + multipartData.forEach {parametersCopy.removeValue(forKey: $0.key)} + // get the first value from the multipart + let first = multipartData.first { $0.value is Data } + + guard let params = parametersCopy as? [String: String], + let data = first?.value as? Data else { throw NetworkError.encodingFailed } + + urlRequest.httpBody = createBody(parameters: params, + boundary: boundary, + data: data, + mimeType: "image/png", + filename: "image.png") + } + + private func createBody(parameters: [String: String], + boundary: String, + data: Data, + mimeType: String, + filename: String) -> Data { + let body = NSMutableData() + + let boundaryPrefix = "--\(boundary)\r\n" + + for (key, value) in parameters { + body.appendString(boundaryPrefix) + body.appendString("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n") + body.appendString("\(value)\r\n") + } + + body.appendString(boundaryPrefix) + body.appendString("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n") + body.appendString("Content-Type: \(mimeType)\r\n\r\n") + body.append(data) + body.appendString("\r\n") + body.appendString("--".appending(boundary.appending("--"))) + + return body as Data + } +} + +extension NSMutableData { + func appendString(_ string: String) { + guard + let data = string.data(using: String.Encoding.utf8, allowLossyConversion: false) + else { fatalError() } + append(data) + } +} diff --git a/Movie/Movie/App/Networking/Encoding/ParameterEncoding.swift b/Movie/Movie/App/Networking/Encoding/ParameterEncoding.swift new file mode 100644 index 00000000..91d02f75 --- /dev/null +++ b/Movie/Movie/App/Networking/Encoding/ParameterEncoding.swift @@ -0,0 +1,59 @@ +// +// ParameterEncoding.swift +// +// +// Created by Andrian Sergheev on 2019-02-25. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +import Foundation + +public typealias Parameters = [String: Any] + +public protocol ParameterEncoder { + func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws +} + +public enum NetworkError: String, Error, Equatable { + case parametersNil = "Parameters were nil." + case encodingFailed = "Parameter encoding failed." + case missingURL = "URL is nil." +} + +public enum ParameterEncoding { + + case none + case urlEncoding + case jsonEncoding + case urlAndjsonEncoding + case multiPartEncoding + + public func encode(urlRequest: inout URLRequest, + bodyParameters: Parameters?, + urlParameters: Parameters?) throws { + do { + switch self { + case .none: + break + case .urlEncoding: + guard let urlParameters = urlParameters else { return } + try URLParameterEncoder().encode(urlRequest: &urlRequest, with: urlParameters) + case .jsonEncoding: + guard let bodyParameters = bodyParameters else { return } + try JSONParameterEncoder().encode(urlRequest: &urlRequest, with: bodyParameters) + case .urlAndjsonEncoding: + guard let bodyParameters = bodyParameters, + let urlParameters = urlParameters else { return } + try URLParameterEncoder().encode(urlRequest: &urlRequest, with: urlParameters) + try JSONParameterEncoder().encode(urlRequest: &urlRequest, with: bodyParameters) + case .multiPartEncoding: + guard let bodyParameters = bodyParameters else { return } + try MultiPartParameterEncoder().encode(urlRequest: &urlRequest, with: bodyParameters) + } + } catch let error { + print("Encoding error: \(error)") + throw error + } + } + +} diff --git a/Movie/Movie/App/Networking/Encoding/URLParameterEncoding.swift b/Movie/Movie/App/Networking/Encoding/URLParameterEncoding.swift new file mode 100644 index 00000000..64b5fd5b --- /dev/null +++ b/Movie/Movie/App/Networking/Encoding/URLParameterEncoding.swift @@ -0,0 +1,32 @@ +// +// URLParameterEncoding.swift +// +// +// Created by Andrian Sergheev on 2019-02-25. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +import Foundation + +public struct URLParameterEncoder: ParameterEncoder { + public func encode(urlRequest: inout URLRequest, with parameters: Parameters) throws { + + guard let url = urlRequest.url else { throw NetworkError.missingURL } + + if var urlComponents = URLComponents(url: url, + resolvingAgainstBaseURL: false), !parameters.isEmpty { + + let queryItems = parameters + .compactMapValues { $0 as? String } + .map(URLQueryItem.init) + + urlComponents.queryItems = queryItems + urlRequest.url = urlComponents.url + } + + if urlRequest.value(forHTTPHeaderField: "Content-Type") == nil { + urlRequest.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type") + } + + } +} diff --git a/Movie/Movie/App/Networking/Endpoint/Movie/MovieEndPoint.swift b/Movie/Movie/App/Networking/Endpoint/Movie/MovieEndPoint.swift new file mode 100644 index 00000000..5bfefbaf --- /dev/null +++ b/Movie/Movie/App/Networking/Endpoint/Movie/MovieEndPoint.swift @@ -0,0 +1,79 @@ +// +// MovieEndPoint.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation + +enum Trending: String { + case daily = "day" + case weekly = "week" +} + +enum MovieApiProvider { + case trending(period: Trending, page: Int, locale: Locale) + case popular(page: Int, locale: Locale) + case topRated(page: Int, locale: Locale) + case genre(locale: Locale) +} + +extension MovieApiProvider: EndPointType { + var baseURL: URL { + MBaseURL.url + } + + var path: String { + switch self { + case .genre: + return "genre/movie/list" + case .popular: + return "movie/popular" + case .topRated: + return "movie/top_rated" + case .trending(period: let trending, _, _): + return "trending/movie/\(trending.rawValue)" + } + } + + var httpMethod: HTTPMethod { + .get + } + + var task: HTTPTask { + + func params(page: Int?, locale: Locale) -> Parameters { + [ + "api_key": "\(Current.token)", + "language": "\(locale)", + "page": page + ] + .compactMapValues { $0 } + } + + switch self { + case .genre(locale: let locale): + return .requestParameters(bodyParameters: nil, + bodyEncoding: .urlEncoding, + urlParameters: params(page: nil, locale: locale)) + case .popular(page: let page, locale: let locale): + return .requestParameters(bodyParameters: nil, + bodyEncoding: .urlEncoding, + urlParameters: params(page: page, locale: locale)) + case .topRated(page: let page, locale: let locale): + return .requestParameters(bodyParameters: nil, + bodyEncoding: .urlEncoding, + urlParameters: params(page: page, locale: locale)) + case .trending(period: _, page: let page, locale: let locale): + return .requestParameters(bodyParameters: nil, + bodyEncoding: .urlEncoding, + urlParameters: params(page: page, locale: locale)) + } + } + + var headers: HTTPHeaders? { + nil + } + +} diff --git a/Movie/Movie/App/Networking/Endpoint/NetworkEnvironment.swift b/Movie/Movie/App/Networking/Endpoint/NetworkEnvironment.swift new file mode 100644 index 00000000..d4220e10 --- /dev/null +++ b/Movie/Movie/App/Networking/Endpoint/NetworkEnvironment.swift @@ -0,0 +1,13 @@ +// +// NetworkEnvironment.swift +// +// +// Created by Andrian Sergheev on 2019-02-25. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +public enum NetworkEnvironment { + case qa + case prod + case staging +} diff --git a/Movie/Movie/App/Networking/Endpoint/Review/ReviewEndPoint.swift b/Movie/Movie/App/Networking/Endpoint/Review/ReviewEndPoint.swift new file mode 100644 index 00000000..1ac52056 --- /dev/null +++ b/Movie/Movie/App/Networking/Endpoint/Review/ReviewEndPoint.swift @@ -0,0 +1,45 @@ +// +// ReviewEndPoint.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation + +public enum ReviewApiProvider { + case fetchReviews(for: Id, page: Int, locale: Locale) +} + +extension ReviewApiProvider: EndPointType { + var baseURL: URL { + MBaseURL.url + } + + var path: String { + switch self { + case .fetchReviews(for: let id, _, _): + return "movie/\(id)/reviews" + } + } + + var httpMethod: HTTPMethod { + .get + } + + var task: HTTPTask { + switch self { + case .fetchReviews(for: _, let page, let locale): + return .requestParameters(bodyParameters: nil, bodyEncoding: .urlEncoding, urlParameters: [ + "api_key": "\(Current.token)", + "language": "\(locale)", + "page": "\(page)" + ]) + } + } + + var headers: HTTPHeaders? { + nil + } + +} diff --git a/Movie/Movie/App/Networking/MBaseURL.swift b/Movie/Movie/App/Networking/MBaseURL.swift new file mode 100644 index 00000000..9b451f20 --- /dev/null +++ b/Movie/Movie/App/Networking/MBaseURL.swift @@ -0,0 +1,28 @@ +// +// MApiConstants.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-24. +// + +import Foundation + +struct MBaseURL { + static let url: URL = URL(string: "https://api.themoviedb.org/3/")! + static let imageURL: URL = URL(string: "https://image.tmdb.org/t/p/w500")! +} + +/* + + main v + - trending today (?) + - popular + - top rated + + search v + - showing search bar w abillity to search movies + + detail v + - showing a view with horizontally scrollable reviews, details of the movie including a picture, and video trailer (if possible) on top. + + */ diff --git a/Movie/Movie/App/Networking/Manager/JSONDecoderHelper.swift b/Movie/Movie/App/Networking/Manager/JSONDecoderHelper.swift new file mode 100644 index 00000000..4a2f1254 --- /dev/null +++ b/Movie/Movie/App/Networking/Manager/JSONDecoderHelper.swift @@ -0,0 +1,36 @@ +// +// JSONDecoderHelper.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation + +func decode(data: Data?, response: URLResponse?, error: Error?) -> Result { + + if let error = error { + return Result.failure(error) + } + + if let response = response as? HTTPURLResponse { + let result = NetworkResponse.handleNetworkResponse(response) + + switch result { + case .success: + guard let data = data else { + return Result.failure(NetworkResponse.noData) + } + + do { + return Result.success(try JSONDecoder().decode(T.self, from: data)) + + } catch let error { + return Result.failure(error) + } + case .failure(let networkError): + return Result.failure(networkError) + } + } + return .failure(NetworkResponse.failed) +} diff --git a/Movie/Movie/App/Networking/Manager/Movie/MovieAPI.swift b/Movie/Movie/App/Networking/Manager/Movie/MovieAPI.swift new file mode 100644 index 00000000..4f2e613b --- /dev/null +++ b/Movie/Movie/App/Networking/Manager/Movie/MovieAPI.swift @@ -0,0 +1,68 @@ +// +// MovieAPI.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation + +struct MovieAPI { + let router: NetworkRouter + + init(_ router: NetworkRouter = NetworkRouter()) { + self.router = router + } +} + +extension MovieAPI { + func fetchGenres( + locale: Locale, + completion: @escaping (Result) -> Void + ) { + router.request(.genre(locale: locale)) { (data, response, error) in + let result: Result = decode(data: data, response: response, error: error) + completion(result) + } + } +} + +extension MovieAPI { + func fetchTrending( + period: Trending, + page: Int, + locale: Locale, + completion: @escaping (Result) -> Void + ) { + router.request(.trending(period: period, page: page, locale: locale)) { data, response, error in + let result: Result = decode(data: data, response: response, error: error) + completion(result) + } + } +} + +extension MovieAPI { + func fetchPopular( + page: Int, + locale: Locale, + completion: @escaping (Result) -> Void + ) { + router.request(.popular(page: page, locale: locale)) { (data, response, error) in + let result: Result = decode(data: data, response: response, error: error) + completion(result) + } + } +} + +extension MovieAPI { + func fetchTopRated( + page: Int, + locale: Locale, + completion: @escaping (Result) -> Void + ) { + router.request(.topRated(page: page, locale: locale)) { (data, response, error) in + let result: Result = decode(data: data, response: response, error: error) + completion(result) + } + } +} diff --git a/Movie/Movie/App/Networking/Manager/Review/ReviewAPI.swift b/Movie/Movie/App/Networking/Manager/Review/ReviewAPI.swift new file mode 100644 index 00000000..480048bd --- /dev/null +++ b/Movie/Movie/App/Networking/Manager/Review/ReviewAPI.swift @@ -0,0 +1,26 @@ +// +// ReviewAPI.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation + +struct ReviewAPI { + let router = NetworkRouter() +} + +extension ReviewAPI { + func fetchReviews( + for movie: Id, + page: Int, + locale: Locale = Current.locale, + completion: @escaping (Result + ) -> Void) { + router.request(.fetchReviews(for: movie, page: page, locale: locale)) { data, response, error in + let result: Result = decode(data: data, response: response, error: error) + completion(result) + } + } +} diff --git a/Movie/Movie/App/Networking/NetworkService/EndPointType.swift b/Movie/Movie/App/Networking/NetworkService/EndPointType.swift new file mode 100644 index 00000000..d2bcbd27 --- /dev/null +++ b/Movie/Movie/App/Networking/NetworkService/EndPointType.swift @@ -0,0 +1,19 @@ +// +// EndPointType.swift +// +// +// Created by Andrian Sergheev on 2019-02-25. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +import Foundation + +public typealias HTTPHeaders = [String: String] + +protocol EndPointType { + var baseURL: URL { get } + var path: String { get } + var httpMethod: HTTPMethod { get } + var task: HTTPTask { get } + var headers: HTTPHeaders? { get } +} diff --git a/Movie/Movie/App/Networking/NetworkService/HTTPMethod.swift b/Movie/Movie/App/Networking/NetworkService/HTTPMethod.swift new file mode 100644 index 00000000..db91dea6 --- /dev/null +++ b/Movie/Movie/App/Networking/NetworkService/HTTPMethod.swift @@ -0,0 +1,15 @@ +// +// HTTPMethod.swift +// +// +// Created by Andrian Sergheev on 2019-02-25. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +public enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" +} diff --git a/Movie/Movie/App/Networking/NetworkService/HTTPTask.swift b/Movie/Movie/App/Networking/NetworkService/HTTPTask.swift new file mode 100644 index 00000000..519ee90c --- /dev/null +++ b/Movie/Movie/App/Networking/NetworkService/HTTPTask.swift @@ -0,0 +1,25 @@ +// +// HTTPTask.swift +// +// +// Created by Andrian Sergheev on 2019-02-25. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +public enum HTTPTask { + +/* + If bodyParameters and urlParameters are nil, + encoding should be .none. +*/ + + case request + case requestParameters(bodyParameters: Parameters?, + bodyEncoding: ParameterEncoding, + urlParameters: Parameters?) + + case requestParametersAndHeaders(bodyParameters: Parameters?, + bodyEncoding: ParameterEncoding, + urlParameters: Parameters?, + additionalHeaders: HTTPHeaders?) +} diff --git a/Movie/Movie/App/Networking/NetworkService/NetworkLogger.swift b/Movie/Movie/App/Networking/NetworkService/NetworkLogger.swift new file mode 100644 index 00000000..e0034c33 --- /dev/null +++ b/Movie/Movie/App/Networking/NetworkService/NetworkLogger.swift @@ -0,0 +1,74 @@ +// +// NetworkLogger.swift +// +// +// Created by Andrian Sergheev on 2019-02-25. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +import Foundation + +final class NetworkLogger { + static func log(request: URLRequest) { + + print("\n - - - - - - - - - - OUTGOING - - - - - - - - - - \n") + defer { print("\n - - - - - - - - - - END - - - - - - - - - - \n") } + + let urlAsString = request.url?.absoluteString ?? "" + let urlComponents = NSURLComponents(string: urlAsString) + + let method = request.httpMethod != nil ? "\(request.httpMethod ?? "")" : "" + let path = "\(urlComponents?.path ?? "")" + let query = "\(urlComponents?.query ?? "")" + let host = "\(urlComponents?.host ?? "")" + + var logOutput = """ + \(urlAsString) \n\n + \(method) \(path)?\(query) HTTP/1.1 \n + HOST: \(host)\n + """ + for (key, value) in request.allHTTPHeaderFields ?? [:] { + logOutput += "\(key): \(value) \n" + } + if let body = request.httpBody { + logOutput += "\n \(NSString(data: body, encoding: String.Encoding.utf8.rawValue) ?? "")" + } + + print(logOutput) + } + + static func logResponse(data: Data?, response: HTTPURLResponse?, error: Error?) { + + print("\n - - - - - - - - - - IN - - - - - - - - - - \n") + defer { print("\n - - - - - - - - - - END - - - - - - - - - - \n") } + + let urlString = response?.url?.absoluteString + let components = NSURLComponents(string: urlString ?? "") + + let path = "\(components?.path ?? "")" + let query = "\(components?.query ?? "")" + + var responseLog: String = "" + if let urlString = urlString { + responseLog += "\(urlString)" + responseLog += "\n\n" + } + + if let statusCode = response?.statusCode { + responseLog += "HTTP \(statusCode) \(path)?\(query)\n" + } + if let host = components?.host { + responseLog += "Host: \(host)\n" + } + for (key, value) in response?.allHeaderFields ?? [:] { + responseLog += "\(key): \(value)\n" + } + if let body = data { + responseLog += "\n\(String(describing: NSString(data: body, encoding: String.Encoding.utf8.rawValue)))\n" + } + if error != nil { + responseLog += "\nError: \(String(describing: error?.localizedDescription))\n" + } + print(responseLog) + } +} diff --git a/Movie/Movie/App/Networking/NetworkService/NetworkRouter.swift b/Movie/Movie/App/Networking/NetworkService/NetworkRouter.swift new file mode 100644 index 00000000..07feb760 --- /dev/null +++ b/Movie/Movie/App/Networking/NetworkService/NetworkRouter.swift @@ -0,0 +1,123 @@ +import Foundation + +public typealias NetworkRouterCompletion = (_ data: Data?, _ response: URLResponse?, _ error: Error?) -> Void + +protocol NetworkRouterProtocol: class { + associatedtype EndPoint: EndPointType + func request(_ route: EndPoint, completion: @escaping NetworkRouterCompletion) + func cancel() +} + +class NetworkRouter: NetworkRouterProtocol { + private var task: URLSessionTask? + + func request(_ route: EndPoint, completion: @escaping NetworkRouterCompletion) { + let session = URLSession.shared + do { + let request = try self.buildRequest(from: route) + #if DEBUG + NetworkLogger.log(request: request) + #endif + task = session.dataTask(with: request, completionHandler: { data, response, error in + completion(data, response, error) + #if DEBUG + NetworkLogger.logResponse(data: data, response: response as? HTTPURLResponse, error: error) + #endif + }) + } catch { + completion(nil, nil, error) + } + self.task?.resume() + } + + func cancel() { + self.task?.cancel() + } + + fileprivate func buildRequest(from route: EndPoint) throws -> URLRequest { + + var request = URLRequest(url: route.baseURL.appendingPathComponent(route.path), + cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, + timeoutInterval: 10.0) + + request.httpMethod = route.httpMethod.rawValue + do { + switch route.task { + case .request: + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + case .requestParameters(let bodyParameters, + let bodyEncoding, + let urlParameters): + + try self.configureParameters(bodyParameters: bodyParameters, + bodyEncoding: bodyEncoding, + urlParameters: urlParameters, + request: &request) + + case .requestParametersAndHeaders(let bodyParameters, + let bodyEncoding, + let urlParameters, + let additionalHeaders): + + self.addAdditionalHeaders(additionalHeaders, request: &request) + try self.configureParameters(bodyParameters: bodyParameters, + bodyEncoding: bodyEncoding, + urlParameters: urlParameters, + request: &request) + } + return request + } catch { + throw error + } + } + + fileprivate func configureParameters(bodyParameters: Parameters?, + bodyEncoding: ParameterEncoding, + urlParameters: Parameters?, + request: inout URLRequest) throws { + do { + try bodyEncoding.encode(urlRequest: &request, + bodyParameters: bodyParameters, urlParameters: urlParameters) + } catch { + throw error + } + } + + fileprivate func addAdditionalHeaders(_ additionalHeaders: HTTPHeaders?, request: inout URLRequest) { + guard let headers = additionalHeaders else { return } + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + +} + +class NetworkRouterMock: NetworkRouter { + + private var storedResponse: Data? + private var storedURLResponse: URLResponse? + private var error: Error? + + func setResponseForRequest(_ data: Data) { + let urlResponse = HTTPURLResponse( + url: MBaseURL.url, + statusCode: 200, + httpVersion: "1.1", + headerFields: nil + )! + self.storedURLResponse = urlResponse + self.storedResponse = data + } + + func setErrorForRequest(_ error: Error?) { + self.storedURLResponse = nil + self.error = error + } + override func request(_ route: EndPoint, completion: @escaping NetworkRouterCompletion) { + completion(storedResponse, storedURLResponse, error) + } + + override func cancel() { + // + } +} diff --git a/Movie/Movie/App/Networking/NetworkService/Response.swift b/Movie/Movie/App/Networking/NetworkService/Response.swift new file mode 100644 index 00000000..d2d96166 --- /dev/null +++ b/Movie/Movie/App/Networking/NetworkService/Response.swift @@ -0,0 +1,30 @@ +// +// Response.swift +// +// +// Created by Andrian Sergheev on 2019-02-25. +// Copyright © 2019 Andrian Sergheev. All rights reserved. +// + +import Foundation + +enum NetworkResponse: String, Error { + case authenticationError = "You need to be authenticated first." + case badRequest = "Bad request" + case outdated = "The url you requested is outdated." + case failed = "Network request failed." + case noData = "Response returned with no data to decode." + case unableToDecode = "Could not decode the response." +} + +extension NetworkResponse { + static func handleNetworkResponse(_ response: HTTPURLResponse) -> Result { + switch response.statusCode { + case 200...299: return .success("Network request OK: 200-299") + case 401...500: return .failure(NetworkResponse.authenticationError) + case 501...599: return .failure(NetworkResponse.badRequest) + case 600: return .failure(NetworkResponse.outdated) + default: return .failure(NetworkResponse.failed) + } + } +} diff --git a/Movie/Movie/App/Networking/RxNetworkingWrappers.swift b/Movie/Movie/App/Networking/RxNetworkingWrappers.swift new file mode 100644 index 00000000..dc4ad857 --- /dev/null +++ b/Movie/Movie/App/Networking/RxNetworkingWrappers.swift @@ -0,0 +1,129 @@ +// +// RxNetworkingWrappers.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation +import RxSwift + +extension ReviewAPI: ReactiveCompatible { } + +extension Reactive where Base == ReviewAPI { + func fetchReviews( + for movie: Id, + page: Int, + locale: Locale = Current.locale + ) -> Observable> { + return .create { observer in + self.base.fetchReviews(for: movie, page: page, locale: locale, completion: { result in + switch result { + case .success(let response): + observer.onNext(.success(response)) + observer.onCompleted() + case .failure(let error): + observer.onNext(.failure(error)) + observer.onCompleted() + } + }) + return Disposables.create { self.base.router.cancel() } + } + } +} + +extension MovieAPI: ReactiveCompatible { } + +extension Reactive where Base == MovieAPI { + func fetchGenres(locale: Locale) -> Observable> { + return .create { observer in + self.base.fetchGenres(locale: locale, completion: { result in + + switch result { + case .success(let response): + observer.onNext(.success(response)) + observer.onCompleted() + case .failure(let error): + observer.onNext(.failure(error)) + observer.onCompleted() + } + + }) + return Disposables.create { self.base.router.cancel() } + } + } +} + +extension Reactive where Base == MovieAPI { + func fetchTrending( + period: Trending, + page: Int, + locale: Locale + ) -> Observable> { + + return .create { observer in + self.base.fetchTrending(period: period, page: page, locale: locale, completion: { result in + + switch result { + case .success(let response): + observer.onNext(.success(response)) + observer.onCompleted() + case .failure(let error): + observer.onNext(.failure(error)) + observer.onCompleted() + } + + }) + return Disposables.create { self.base.router.cancel() } + } + + } +} + +extension Reactive where Base == MovieAPI { + func fetchPopular( + page: Int, + locale: Locale + ) -> Observable> { + + return .create { observer in + self.base.fetchPopular(page: page, locale: locale, completion: { result in + switch result { + case .success(let response): + observer.onNext(.success(response)) + observer.onCompleted() + case .failure(let error): + observer.onNext(.failure(error)) + observer.onCompleted() + } + + }) + return Disposables.create { self.base.router.cancel() } + } + + } +} + +extension Reactive where Base == MovieAPI { + func fetchTopRated( + page: Int, + locale: Locale + ) -> Observable> { + + return .create { observer in + self.base.fetchTopRated(page: page, locale: locale, completion: { result in + switch result { + case .success(let response): + observer.onNext(.success(response)) + observer.onCompleted() + case .failure(let error): + observer.onNext(.failure(error)) + observer.onCompleted() + } + + }) + return Disposables.create { self.base.router.cancel() } + } + + } +} diff --git a/Movie/Movie/App/SceneDelegate.swift b/Movie/Movie/App/SceneDelegate.swift new file mode 100644 index 00000000..2f64d7e9 --- /dev/null +++ b/Movie/Movie/App/SceneDelegate.swift @@ -0,0 +1,52 @@ +// +// SceneDelegate.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-24. +// + +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). + if #available(iOS 13.0, *) { + window?.overrideUserInterfaceStyle = .light + } + guard let _ = (scene as? UIWindowScene) else { return } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not 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/Movie/Movie/App/Shared/ActivityIndicator.swift b/Movie/Movie/App/Shared/ActivityIndicator.swift new file mode 100644 index 00000000..b7aec920 --- /dev/null +++ b/Movie/Movie/App/Shared/ActivityIndicator.swift @@ -0,0 +1,82 @@ +// +// ActivityIndicator.swift +// RxExample +// +// Created by Krunoslav Zaher on 10/18/15. +// Copyright © 2015 Krunoslav Zaher. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +// swiftlint:disable all +private struct ActivityToken : ObservableConvertibleType, Disposable { + private let _source: Observable + private let _dispose: Cancelable + + init(source: Observable, disposeAction: @escaping () -> Void) { + _source = source + _dispose = Disposables.create(with: disposeAction) + } + + func dispose() { + _dispose.dispose() + } + + func asObservable() -> Observable { + return _source + } +} + +/** +Enables monitoring of sequence computation. +If there is at least one sequence computation in progress, `true` will be sent. +When all activities complete `false` will be sent. +*/ +public class RxActivityIndicator : SharedSequenceConvertibleType { + public typealias Element = Bool + public typealias SharingStrategy = DriverSharingStrategy + + private let _lock = NSRecursiveLock() + private let _relay = BehaviorRelay(value: 0) + private let _loading: SharedSequence + + public init() { + _loading = _relay.asDriver() + .map { $0 > 0 } + .distinctUntilChanged() + } + + fileprivate func trackActivityOfObservable(_ source: Source) -> Observable { + return Observable.using({ () -> ActivityToken in + self.increment() + return ActivityToken(source: source.asObservable(), disposeAction: self.decrement) + }) { t in + return t.asObservable() + } + } + + private func increment() { + _lock.lock() + _relay.accept(_relay.value + 1) + _lock.unlock() + } + + private func decrement() { + _lock.lock() + _relay.accept(_relay.value - 1) + _lock.unlock() + } + + public func asSharedSequence() -> SharedSequence { + return _loading + } +} + +extension ObservableConvertibleType { + public func trackActivity(_ activityIndicator: RxActivityIndicator) -> Observable { + return activityIndicator.trackActivityOfObservable(self) + } +} +//swiftlint: enable all diff --git a/Movie/Movie/App/Shared/Extensions/NSObject+Ext.swift b/Movie/Movie/App/Shared/Extensions/NSObject+Ext.swift new file mode 100644 index 00000000..dd8f5031 --- /dev/null +++ b/Movie/Movie/App/Shared/Extensions/NSObject+Ext.swift @@ -0,0 +1,18 @@ +// +// NSObject+Ext.swift +// +// +// Created by Andrian Sergheev on 2020-06-10. +// Copyright © 2020 Andrian Sergheev. All rights reserved. +// + +import Foundation.NSObject + +extension NSObject { + class var className: String { + guard let name = NSStringFromClass(self).components(separatedBy: ".").last else { + fatalError("Accessed wrong defined name of a class") + } + return name + } +} diff --git a/Movie/Movie/App/Shared/Extensions/UIScrollView+reachedBottom.swift b/Movie/Movie/App/Shared/Extensions/UIScrollView+reachedBottom.swift new file mode 100644 index 00000000..28a2a0bd --- /dev/null +++ b/Movie/Movie/App/Shared/Extensions/UIScrollView+reachedBottom.swift @@ -0,0 +1,25 @@ +#if os(iOS) +import UIKit +import RxSwift +import RxCocoa + +public extension Reactive where Base: UIScrollView { + /** + Shows if the bottom of the UIScrollView is reached. + - parameter offset: A threshhold indicating the bottom of the UIScrollView. + - returns: ControlEvent that emits when the bottom of the base UIScrollView is reached. + */ + func reachedBottom(offset: CGFloat = 0.0) -> ControlEvent { + let source = contentOffset.map { contentOffset in + let visibleHeight = self.base.frame.height - self.base.contentInset.top - self.base.contentInset.bottom + let yAxis = contentOffset.y + self.base.contentInset.top + let threshold = max(offset, self.base.contentSize.height - visibleHeight) + return yAxis >= threshold + } + .distinctUntilChanged() + .filter { $0 } + .map { _ in () } + return ControlEvent(events: source) + } +} +#endif diff --git a/Movie/Movie/App/Shared/MIcons.swift b/Movie/Movie/App/Shared/MIcons.swift new file mode 100644 index 00000000..00927c72 --- /dev/null +++ b/Movie/Movie/App/Shared/MIcons.swift @@ -0,0 +1,12 @@ +// +// MIcons.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-26. +// + +import Foundation + +// struct MIcons { +// // +// } diff --git a/Movie/Movie/App/Shared/Shared.swift b/Movie/Movie/App/Shared/Shared.swift new file mode 100644 index 00000000..ba013527 --- /dev/null +++ b/Movie/Movie/App/Shared/Shared.swift @@ -0,0 +1,27 @@ +// +// Shared.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation + +public typealias Id = Int +public typealias Locale = String + +public func extractSuccess(_ result: Result) -> T? { + if case let .success(value) = result { + return value + } else { + return nil + } +} + +public func extractFailure(_ result: Result) -> Error? { + if case let .failure(error) = result { + return error + } else { + return nil + } +} diff --git a/Movie/Movie/App/Shared/Store.swift b/Movie/Movie/App/Shared/Store.swift new file mode 100644 index 00000000..6f7b8a32 --- /dev/null +++ b/Movie/Movie/App/Shared/Store.swift @@ -0,0 +1,44 @@ +// +// Store.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation +import RxSwift +/* + Something like this https://github.com/NoTests/RxFeedback.swift could be used, however kept this implementation for simplicity, + */ + +protocol Redux { + associatedtype State: Equatable + associatedtype Action + static func reduce(state: State, action: Action) -> State +} + +class Store { + + let state: Observable + + private let actions = PublishSubject() + + init(initialState: State, reducer: @escaping (State, Action) -> State) { + state = actions + .scan(initialState, accumulator: reducer) + .startWith(initialState) + .share(replay: 1) + } +} + +extension Store: ObserverType { + + // swiftlint:disable:next type_name + typealias E = Action + + func on(_ event: Event) { + if let element = event.element { + actions.onNext(element) + } + } +} diff --git a/Movie/Movie/App/VC/Cell/MovieCollectionViewCell.swift b/Movie/Movie/App/VC/Cell/MovieCollectionViewCell.swift new file mode 100644 index 00000000..ea5404de --- /dev/null +++ b/Movie/Movie/App/VC/Cell/MovieCollectionViewCell.swift @@ -0,0 +1,44 @@ +// +// MovieCollectionViewCell.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-26. +// + +import UIKit +import Nuke + +final class MovieCollectionViewCell: UICollectionViewCell { + + @IBOutlet weak var backgroundImageView: UIImageView! + @IBOutlet weak var mainLabel: UILabel! + @IBOutlet weak var detailLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + setupUI() + } + + override func prepareForReuse() { + backgroundImageView.image = nil + Nuke.cancelRequest(for: backgroundImageView) + } + + // this normally should be passed from a cell viewModel. Due to simplicity of the data however, it can be done like this as well. + public func setupCell(model: Movie) { + mainLabel.text = model.title + detailLabel.text = model.releaseDate + + let resize = ImageProcessors.Resize(size: backgroundImageView.bounds.size) + let req = ImageRequest(url: model.backDropUrl, processors: [resize]) + Nuke.loadImage(with: req, into: backgroundImageView) + } + private func setupUI() { + mainLabel.font = UIFont.systemFont(ofSize: 20) + mainLabel.numberOfLines = 0 + mainLabel.textColor = .white + detailLabel.font = UIFont.systemFont(ofSize: 15) + detailLabel.textColor = .white + } + +} diff --git a/Movie/Movie/App/VC/Cell/MovieCollectionViewCell.xib b/Movie/Movie/App/VC/Cell/MovieCollectionViewCell.xib new file mode 100644 index 00000000..1aed5ec3 --- /dev/null +++ b/Movie/Movie/App/VC/Cell/MovieCollectionViewCell.xib @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Movie/Movie/App/VC/DetailViewController.swift b/Movie/Movie/App/VC/DetailViewController.swift new file mode 100644 index 00000000..1474dff5 --- /dev/null +++ b/Movie/Movie/App/VC/DetailViewController.swift @@ -0,0 +1,86 @@ +// +// DetailViewController.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-26. +// + +import UIKit +import RxSwift +import Nuke + +final class DetailViewController: UIViewController { + + @IBOutlet weak var cancelButton: UIButton! + @IBOutlet weak var posterImageView: UIImageView! + @IBOutlet weak var backgroundImageView: UIImageView! + @IBOutlet weak var movieName: UILabel! + @IBOutlet weak var releaseLabel: UILabel! + @IBOutlet weak var releaseLabelText: UILabel! + @IBOutlet weak var genreLabel: UILabel! + @IBOutlet weak var genreLabelText: UILabel! + @IBOutlet weak var descriptionLabel: UILabel! + + var viewModel: DetailViewModelType! + + private let disposeBag = DisposeBag() + + override func viewDidLoad() { + super.viewDidLoad() + // view.backgroundColor = .red + setupComponents() + bindViewModel() + } + + private func setupComponents() { + view.bringSubviewToFront(cancelButton) + + cancelButton + .setImage(UIImage(systemName: "xmark"), for: [.normal]) + cancelButton.tintColor = .black + cancelButton.setTitle("", for: [.normal]) + cancelButton.rx.tap + .asObservable() + .observe(on: MainScheduler.instance) + .take(1) + .subscribe(onNext: { [weak self] _ in self?.dismiss(animated: true)}) + .disposed(by: disposeBag) + + releaseLabel.text = "Release Date" + genreLabel.text = "Genre" + genreLabelText.numberOfLines = 0 + descriptionLabel.numberOfLines = 0 + backgroundImageView.contentMode = .scaleAspectFill + } + + private func bindViewModel() { + let output = viewModel.output + + movieName.text = output.title + releaseLabelText.text = output.release + descriptionLabel.text = output.description + + output.genre + .observe(on: MainScheduler.instance) + .bind(to: genreLabelText.rx.text) + .disposed(by: disposeBag) + + /* + Even though it does not look like it, the image is returned from the cache, if downloaded before. + */ + + let backgroundReq = ImageRequest(url: output.backgroundImageURL, processors: [ + ImageProcessors.Resize(size: backgroundImageView.bounds.size)]) + Nuke.loadImage(with: backgroundReq, into: backgroundImageView) + + let posterReq = ImageRequest(url: output.posterImageURL, processors: [ImageProcessors.Resize(size: posterImageView.bounds.size)]) + Nuke.loadImage(with: posterReq, into: posterImageView) + + } + + #if DEBUG + deinit { + print("\(self) de-init") + } + #endif +} diff --git a/Movie/Movie/App/VC/Layout/VerticalFlowLayout.swift b/Movie/Movie/App/VC/Layout/VerticalFlowLayout.swift new file mode 100644 index 00000000..79b6920e --- /dev/null +++ b/Movie/Movie/App/VC/Layout/VerticalFlowLayout.swift @@ -0,0 +1,62 @@ +// +// VerticalFlowLayout.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-26. +// + +import Foundation + +/* + snatched from: + https://github.com/DeluxeAlonso/UpcomingMovies/blob/development/UpcomingMovies/ViewComponents/Layouts/VerticalFlowLayout.swift + */ + +import UIKit + +final class VerticalFlowLayout: UICollectionViewFlowLayout { + + private var preferredWidth: CGFloat + private var preferredHeight: CGFloat + private let margin: CGFloat + private let minColumns: Int + + init(preferredWidth: CGFloat, + preferredHeight: CGFloat, + margin: CGFloat = 16.0, + minColumns: Int = .zero) { + self.preferredWidth = preferredWidth + self.preferredHeight = preferredHeight + self.margin = margin + self.minColumns = minColumns + super.init() + + sectionInsetReference = .fromSafeArea + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + override func prepare() { + super.prepare() + var finalWidth = preferredWidth + var finalHeight = preferredHeight + if minColumns != .zero, let collectionView = collectionView { + let totalHorzontalSafeAreaInset = collectionView.safeAreaInsets.left + collectionView.safeAreaInsets.right + + let horizontalSpacePerItem = margin * 2 + totalHorzontalSafeAreaInset + minimumInteritemSpacing + let totalHorizontalSpace = horizontalSpacePerItem * CGFloat(minColumns - 1) + let maximumItemWidth = ((collectionView.bounds.size.width - totalHorizontalSpace) / CGFloat(minColumns)).rounded(.down) + + if maximumItemWidth < preferredWidth { + finalWidth = maximumItemWidth + finalHeight = finalWidth * (preferredHeight / preferredWidth) + } + } + + itemSize = CGSize(width: finalWidth, height: finalHeight) + sectionInset = UIEdgeInsets(top: margin, left: margin, + bottom: margin, right: margin) + } +} diff --git a/Movie/Movie/App/VC/ViewController.swift b/Movie/Movie/App/VC/ViewController.swift new file mode 100644 index 00000000..68de544f --- /dev/null +++ b/Movie/Movie/App/VC/ViewController.swift @@ -0,0 +1,185 @@ +// +// ViewController.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-24. +// + +import UIKit +import RxSwift +import RxDataSources + +final class ViewController: UIViewController { + + private let disposeBag = DisposeBag() + var viewModel: HomeContainerViewModelType! + @IBOutlet weak var collectionView: UICollectionView! + + override func viewDidLoad() { + super.viewDidLoad() + // in a larger scale app, this should be passed from outside. + viewModel = HomeContainerViewModel(movieApi: MovieAPI()) + + viewModel.input.fetchMovies(filter: .popular) // def + + setupComponents() + bindViewModel() + } + + let refreshControl = UIRefreshControl() + + private func setupComponents() { + collectionView.delegate = nil + collectionView.dataSource = nil + + collectionView + .register(UINib(nibName: MovieCollectionViewCell.className, bundle: nil), + forCellWithReuseIdentifier: HomeDataSource.cellReuseIdentifier) + collectionView.refreshControl = refreshControl + + let width = collectionView.frame.width - 32 + let layout = VerticalFlowLayout(preferredWidth: width, + preferredHeight: 200) + + collectionView.collectionViewLayout = layout + + let image = UIImage(systemName: "film")! + let barButtonItem = UIBarButtonItem( + image: image, + style: .done, + target: self, + action: #selector(filterButtonTapped) + ) + barButtonItem.tintColor = .black + + navigationItem.rightBarButtonItems = [barButtonItem] + } + + @objc func filterButtonTapped() { + showAlertAction() + } + + private func bindViewModel() { + let output = viewModel.output + let input = viewModel.input + + // MARK: - Input + refreshControl.rx + .controlEvent(.allEvents) + .asObservable() + .debounce(.seconds(1), scheduler: MainScheduler.instance) + .withLatestFrom(output.pickedFilter) + .subscribe(onNext: { filter in + input.fetchMovies(filter: filter) + }) + .disposed(by: disposeBag) + + collectionView.rx + .reachedBottom(offset: 1) + // .skip(1) + .debounce(.milliseconds(500), scheduler: MainScheduler.instance) + .subscribe(onNext: { _ in input.loadMore() }) + .disposed(by: disposeBag) + + // MARK: - Output + + let movies = output.movies + .observe(on: MainScheduler.instance) + .share() + + movies + .map { $0.isEmpty } + .subscribe() // show an empty view, etc. + .disposed(by: disposeBag) + + movies + .map { [HomeDataSource.Model(model: "", items: $0)]} + .bind(to: collectionView.rx.items(dataSource: HomeDataSource.dataSource())) + .disposed(by: disposeBag) + + output.error + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] error in + #if DEBUG + print("Error: \(error)") + #endif + self?.errorAlert(error) + }).disposed(by: disposeBag) + + let isLoading = + output.isLoading + // .debug("🤩") + .observe(on: MainScheduler.instance) + + isLoading + .bind(to: refreshControl.rx.isRefreshing) + .disposed(by: disposeBag) + + output.pickedFilter + .map { $0.rawValue } + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] title in + self?.title = title + }) + .disposed(by: disposeBag) + + // MARK: - Navigation + + // normally i'd pass this up to the coordinators. However, since only have two views + // it is ok to do it like this. + + collectionView.rx + .modelSelected(Movie.self) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] movie in + let vm = DetailViewModel(movie: movie) + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let vc = storyboard.instantiateViewController(identifier: DetailViewController.className) as! DetailViewController + vc.viewModel = vm + self?.present(vc, animated: true, completion: nil) + }) + .disposed(by: disposeBag) + } + + private func showAlertAction() { + let alertController = UIAlertController(title: "", + message: "Pick a filter!", + preferredStyle: .actionSheet) + + let popularAction = UIAlertAction( + title: "Popular", style: .default, handler: { [weak self] _ in self?.viewModel.input.fetchMovies(filter: .popular) + } + ) + + let trendingAction = UIAlertAction( + title: "Trending(Daily)", style: .default, handler: { [weak self] _ in self?.viewModel.input.fetchMovies(filter: .trending) + } + ) + + let topRatedAction = UIAlertAction( + title: "TopRated", style: .default, handler: { [weak self] _ in self?.viewModel.input.fetchMovies(filter: .topRated) + } + ) + + let cancelAction = UIAlertAction( + title: "Cancel", + style: .cancel, + handler: nil + ) + alertController.addAction(popularAction) + alertController.addAction(trendingAction) + alertController.addAction(topRatedAction) + alertController.addAction(cancelAction) + present(alertController, animated: true, completion: nil) + } + + private func errorAlert(_ error: Error) { + let alert = UIAlertController( + title: "Error", message: error.localizedDescription, preferredStyle: .alert + ) + let okAction = UIAlertAction(title: "Ok", style: .default, handler: nil) + alert.addAction(okAction) + present(alert, animated: true, completion: nil) + } + +} diff --git a/Movie/Movie/App/VM/DetailViewModel.swift b/Movie/Movie/App/VM/DetailViewModel.swift new file mode 100644 index 00000000..93704a2a --- /dev/null +++ b/Movie/Movie/App/VM/DetailViewModel.swift @@ -0,0 +1,74 @@ +// +// DetailViewModel.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-26. +// + +import Foundation +import RxSwift + +protocol DetailViewModelInput { + // +} + +protocol DetailViewModelOutput { + var backgroundImageURL: URL { get } + var posterImageURL: URL { get } + + var title: String { get } + var genre: Observable { get } + var release: String { get } + var description: String { get } +} + +protocol DetailViewModelType { + var input: DetailViewModelInput { get } + var output: DetailViewModelOutput { get } +} + +final class DetailViewModel: DetailViewModelInput, + DetailViewModelOutput, + DetailViewModelType { + + var backgroundImageURL: URL + var posterImageURL: URL + var title: String + var genre: Observable + var release: String + var description: String + + var input: DetailViewModelInput { self } + var output: DetailViewModelOutput { self } + + init(movie: Movie, api: MovieAPI = MovieAPI()) { + title = movie.title + release = movie.releaseDate + description = movie.overview + + backgroundImageURL = movie.backDropUrl + posterImageURL = movie.posterUrl + + let genre = api.rx // this should be probably cached. + .fetchGenres(locale: Current.locale) + .map { result -> String in + switch result { + case .success(let payload): + return payload.genres.filter { genre in + movie.genreIDS.contains(genre.id) + } + .map { $0.name } + .joined(separator: ",") + case .failure: + return "" + } + } + self.genre = genre + + } + #if DEBUG + deinit { + print("\(self) de-init") + } + #endif +} diff --git a/Movie/Movie/App/VM/MainViewModel.swift b/Movie/Movie/App/VM/MainViewModel.swift new file mode 100644 index 00000000..527d2b02 --- /dev/null +++ b/Movie/Movie/App/VM/MainViewModel.swift @@ -0,0 +1,208 @@ +// +// MainViewModel.swift +// Movie +// +// Created by Adrian Sergheev on 2021-03-25. +// + +import Foundation +import RxSwift +import RxRelay +import RxCocoa +import RxDataSources + +struct HomeDataSource { + + static let cellReuseIdentifier = "movieCell" + + typealias Model = SectionModel + typealias DataSource = RxCollectionViewSectionedReloadDataSource + + static func dataSource() -> DataSource { + return .init(configureCell: { _, collectionView, indexPath, model in + + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Self.cellReuseIdentifier, + for: indexPath) as! MovieCollectionViewCell + + cell.setupCell(model: model) + return cell + }) + } +} + +public enum MovieCategory: String { + case trending = "Trending" + case topRated = "Top Rated" + case popular = "Popular" +} + +protocol HomeContainerViewModelInput { + func fetchMovies(filter: MovieCategory) + func loadMore() +} + +protocol HomeContainerViewModelOutput { + var movies: Observable<[Movie]> { get } + var isLoading: Observable { get } + var pickedFilter: Observable { get } + var error: Observable { get } +} + +protocol HomeContainerViewModelType: class { + var input: HomeContainerViewModelInput { get } + var output: HomeContainerViewModelOutput { get } +} + +final class HomeContainerViewModel: HomeContainerViewModelInput, + HomeContainerViewModelOutput, + HomeContainerViewModelType, + Redux { + + typealias HomeStore = Store + + struct State: Equatable { + + var filter: MovieCategory = .trending + var movies: [Movie] = [] + var page: Int = 1 + var error: Error? + + static func == (lhs: HomeContainerViewModel.State, rhs: HomeContainerViewModel.State) -> Bool { + lhs.movies == rhs.movies && lhs.filter == rhs.filter && lhs.page == rhs.page + } + } + + enum Action { + case filterSelected(MovieCategory) + case movies([Movie]) + case reachedBottom + case error(Error?) + } + + static func reduce(state: State, action: Action) -> State { + var state = state + + switch action { + case .filterSelected(let category): + state.filter = category + state.movies = [] + case .movies(let movies): + state.movies.append(contentsOf: movies) + state.error = nil + case .reachedBottom: + state.page += 1 + case .error(let error): + // state.page = 1 + // state.movies = [] + state.error = error + } + return state + } + + // MARK: - Input + func fetchMovies(filter: MovieCategory) { + filterRelay.accept(filter) + } + + func loadMore() { + loadMoreRelay.accept(Void()) + } + + // MARK: - Output + var movies: Observable<[Movie]> + var isLoading: Observable + var pickedFilter: Observable + var error: Observable + + var input: HomeContainerViewModelInput { self } + var output: HomeContainerViewModelOutput { self } + + // this pattern works great also when the state has to be restored from some kind of storage. Here for example, we could have injected the cached movies retrieved from previous session, etc. + private let store = HomeStore(initialState: State(), + reducer: HomeContainerViewModel.reduce) + private let disposeBag = DisposeBag() + + init(movieApi: MovieAPI) { + + let state = store.state + + // input + filterRelay + .asObservable() + .map(Action.filterSelected) + .observe(on: MainScheduler.instance) + .bind(to: store) + .disposed(by: disposeBag) + + loadMoreRelay + .asObservable() + .map { _ in Action.reachedBottom } + .observe(on: MainScheduler.instance) + .bind(to: store) + .disposed(by: disposeBag) + + // output + let activityIndicator = RxActivityIndicator() + + let rxApi = movieApi.rx + + let moviesResult = Observable.merge( + filterRelay.asObservable().map { _ in }, + loadMoreRelay.asObservable() + ) + .withLatestFrom(state) + // .debug("😇") + .flatMapLatest { state -> Observable> in + switch state.filter { + case .popular: + return rxApi + .fetchPopular(page: state.page, locale: Current.locale) + .trackActivity(activityIndicator) + case .trending: + return rxApi + .fetchTrending(period: .daily, page: state.page, locale: Current.locale) + .trackActivity(activityIndicator) + case .topRated: + return rxApi + .fetchTopRated(page: state.page, locale: Current.locale) + .trackActivity(activityIndicator) + } + }.share() + + /* + best would be to send a typed error with the localized description according to the model coming from the web, for example: + { + "status_code": 7, + "status_message": "Invalid API key: You must be granted a valid key.", + "success": false + } + */ + moviesResult + .map(extractFailure) + .compactMap { error in Action.error(error) } + .bind(to: store) + .disposed(by: disposeBag) + + moviesResult + .map(extractSuccess) + .compactMap { $0?.results } + .map(Action.movies) + .bind(to: store) + .disposed(by: disposeBag) + + self.isLoading = activityIndicator.asObservable() + self.movies = state.map { $0.movies } + self.pickedFilter = state.map { $0.filter } + self.error = state.compactMap { $0.error } + } + + #if DEBUG + deinit { + print("\(self) de-init") + } + #endif + + private let filterRelay = PublishRelay() + private let loadMoreRelay = PublishRelay() + +} diff --git a/Movie/Movie/Assets.xcassets/AccentColor.colorset/Contents.json b/Movie/Movie/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/Movie/Movie/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/1024.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 00000000..f3d59fb8 Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/1024.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/114.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/114.png new file mode 100644 index 00000000..1d259976 Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/114.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/120.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/120.png new file mode 100644 index 00000000..c2310074 Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/120.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/180.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/180.png new file mode 100644 index 00000000..a9485fac Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/180.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/29.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/29.png new file mode 100644 index 00000000..5c614851 Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/29.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/40.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/40.png new file mode 100644 index 00000000..680014a2 Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/40.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/57.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/57.png new file mode 100644 index 00000000..ed2807f8 Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/57.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/58.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/58.png new file mode 100644 index 00000000..65eeecbe Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/58.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/60.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/60.png new file mode 100644 index 00000000..66115cac Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/60.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/80.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/80.png new file mode 100644 index 00000000..94151913 Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/80.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/87.png b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/87.png new file mode 100644 index 00000000..c58e26a4 Binary files /dev/null and b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/87.png differ diff --git a/Movie/Movie/Assets.xcassets/AppIcon.appiconset/Contents.json b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..af727e0c --- /dev/null +++ b/Movie/Movie/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,80 @@ +{ + "images" : [ + { + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" + }, + { + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Movie/Movie/Assets.xcassets/Contents.json b/Movie/Movie/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Movie/Movie/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Movie/Movie/Base.lproj/LaunchScreen.storyboard b/Movie/Movie/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/Movie/Movie/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Movie/Movie/Base.lproj/Main.storyboard b/Movie/Movie/Base.lproj/Main.storyboard new file mode 100644 index 00000000..3b295c33 --- /dev/null +++ b/Movie/Movie/Base.lproj/Main.storyboard @@ -0,0 +1,312 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Movie/Movie/Info.plist b/Movie/Movie/Info.plist new file mode 100644 index 00000000..5b531f7b --- /dev/null +++ b/Movie/Movie/Info.plist @@ -0,0 +1,66 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Movie/MovieTests/Info.plist b/Movie/MovieTests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/Movie/MovieTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Movie/MovieTests/ModelTest.swift b/Movie/MovieTests/ModelTest.swift new file mode 100644 index 00000000..1a5e4341 --- /dev/null +++ b/Movie/MovieTests/ModelTest.swift @@ -0,0 +1,35 @@ +// +// ModelTest.swift +// MovieTests +// +// Created by Adrian Sergheev on 2021-03-30. +// + +import XCTest +@testable import Movie + +class ModelTest: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + + func testInitMoviePayload() throws { + try testModel(model: MoviePayload.self, jsonFilename: TestFilenames.trending.rawValue) + } + + func testInitGenrePayload() throws { + try testModel(model: GenrePayload.self, jsonFilename: TestFilenames.genre.rawValue) + } + + func testInitReviewPayload() throws { + try testModel(model: ReviewPayload.self, jsonFilename: TestFilenames.reviews.rawValue) + + } + +} diff --git a/Movie/MovieTests/MovieTests.swift b/Movie/MovieTests/MovieTests.swift new file mode 100644 index 00000000..40863b09 --- /dev/null +++ b/Movie/MovieTests/MovieTests.swift @@ -0,0 +1,144 @@ +// +// MovieTests.swift +// MovieTests +// +// Created by Adrian Sergheev on 2021-03-30. +// + +import XCTest +import RxTest +import RxSwift + +@testable import Movie + +class MovieTests: XCTestCase { + + var viewModel: HomeContainerViewModelType! + var scheduler: TestScheduler! + var disposeBag: DisposeBag! + + var movieAPI: MovieAPI! + var router: NetworkRouterMock! + + override func setUp() { + super.setUp() + self.scheduler = TestScheduler(initialClock: 0) + self.disposeBag = DisposeBag() + + self.router = NetworkRouterMock() + self.movieAPI = MovieAPI(router) + + self.viewModel = HomeContainerViewModel(movieApi: movieAPI) + } + + override func tearDown() { + self.viewModel = nil + self.movieAPI = nil + self.router = nil + } + + + // MARK: - HomeContainerViewModel + + func testFetchWithError() throws { + + let injectedError = NetworkError.parametersNil + router.setErrorForRequest(injectedError) + + let error = scheduler.createObserver(NetworkError.self) + + scheduler.createHotObservable([.next(10, Void() )]) + .subscribe(onNext: { [weak self] _ in self?.viewModel.input.fetchMovies(filter: .popular)}) + .disposed(by: disposeBag) + + viewModel.output + .error + .compactMap { $0 as? NetworkError } + .bind(to: error) + .disposed(by: disposeBag) + + scheduler.start() + + XCTAssertEqual(error.events, [.next(10, injectedError)]) + } + + func testFetchWithSuccess() throws { + + let movies = scheduler.createObserver([Movie].self) + + let response = try loadJson(TestFilenames.trending.rawValue) + router.setResponseForRequest(response) + + scheduler.createHotObservable([.next(10, Void() )]) + .subscribe(onNext: { [weak self] _ in self?.viewModel.input.fetchMovies(filter: .popular)}) + .disposed(by: disposeBag) + + viewModel.output + .movies + // .skip(3) // one for inital init of the state, two and three for zeroing the movies by fetchMovies(filter:) call. + // .debug("🙂") + .bind(to: movies) + .disposed(by: disposeBag) + + scheduler.start() + + //get all the events, flatten them in one array , make sure that there are eventually movies received + let events = movies.events + .compactMap { $0.value } + .compactMap { $0.element } + .flatMap { $0 } + + XCTAssertNotEqual(events.isEmpty, true) + } + + func testInputIsPreserved() throws { + + let response = try loadJson(TestFilenames.trending.rawValue) + router.setResponseForRequest(response) + + let category = scheduler.createObserver(MovieCategory.self) + + scheduler.createHotObservable( + [.next(10, MovieCategory.popular), + .next(11, .topRated), + .next(12, .trending)]) + .subscribe(onNext: { [weak self] element in self?.viewModel.input.fetchMovies(filter: element)}) + .disposed(by: disposeBag) + + + viewModel.output + .pickedFilter + .skip(1) //skip first, intialized in the vm state machine + .distinctUntilChanged() + .bind(to: category) + .disposed(by: disposeBag) + + scheduler.start() + + XCTAssertEqual(category.events, [.next(10, MovieCategory.popular), + .next(11, MovieCategory.topRated), + .next(12, MovieCategory.trending)]) + } + + func testIsLoading() throws { + + let response = try loadJson(TestFilenames.trending.rawValue) + router.setResponseForRequest(response) + let isLoading = scheduler.createObserver(Bool.self) + + scheduler.createHotObservable([.next(10, MovieCategory.popular)]) + .subscribe(onNext: { [weak self] element in self?.viewModel.input.fetchMovies(filter: element)}) + .disposed(by: disposeBag) + + viewModel.output + .isLoading + .skip(1) + .bind(to: isLoading) + .disposed(by: disposeBag) + + scheduler.start() + + XCTAssertEqual(isLoading.events, [.next(10, true), .next(10, false)]) + } + +} diff --git a/Movie/MovieTests/TestsHelper.swift b/Movie/MovieTests/TestsHelper.swift new file mode 100644 index 00000000..625e0b83 --- /dev/null +++ b/Movie/MovieTests/TestsHelper.swift @@ -0,0 +1,38 @@ +// +// TestsHelper.swift +// MovieTests +// +// Created by Adrian Sergheev on 2021-03-30. +// + +import Foundation +import XCTest + +enum TestFilenames: String { + case genre + case reviews + case trending +} + +enum FailMessage: Error { + case modelDataShouldNotBeNil +} + +func loadJson(_ fileName: String) throws -> Data { + if let path = Bundle.main.path(forResource: fileName, ofType: "json") { + let fileUrl = URL(fileURLWithPath: path) + let data = try Data(contentsOf: fileUrl, options: .mappedIfSafe) + return data + } else { + throw FailMessage.modelDataShouldNotBeNil + } +} + +func decodeModel(data: Data) throws -> T { + return try JSONDecoder().decode(T.self, from: data) +} + +func testModel(model: T.Type, jsonFilename: String) throws { + let data = try loadJson(jsonFilename) + let _: T = try decodeModel(data: data) +} diff --git a/Movie/MovieTests/genre.json b/Movie/MovieTests/genre.json new file mode 100644 index 00000000..3d8140a3 --- /dev/null +++ b/Movie/MovieTests/genre.json @@ -0,0 +1,80 @@ +{ + "genres": [ + { + "id": 28, + "name": "Action" + }, + { + "id": 12, + "name": "Adventure" + }, + { + "id": 16, + "name": "Animation" + }, + { + "id": 35, + "name": "Comedy" + }, + { + "id": 80, + "name": "Crime" + }, + { + "id": 99, + "name": "Documentary" + }, + { + "id": 18, + "name": "Drama" + }, + { + "id": 10751, + "name": "Family" + }, + { + "id": 14, + "name": "Fantasy" + }, + { + "id": 36, + "name": "History" + }, + { + "id": 27, + "name": "Horror" + }, + { + "id": 10402, + "name": "Music" + }, + { + "id": 9648, + "name": "Mystery" + }, + { + "id": 10749, + "name": "Romance" + }, + { + "id": 878, + "name": "Science Fiction" + }, + { + "id": 10770, + "name": "TV Movie" + }, + { + "id": 53, + "name": "Thriller" + }, + { + "id": 10752, + "name": "War" + }, + { + "id": 37, + "name": "Western" + } + ] +} \ No newline at end of file diff --git a/Movie/MovieTests/reviews.json b/Movie/MovieTests/reviews.json new file mode 100644 index 00000000..8afb0b94 --- /dev/null +++ b/Movie/MovieTests/reviews.json @@ -0,0 +1,78 @@ +{ + "id": 791373, + "page": 1, + "results": [ + { + "author": "msbreviews", + "author_details": { + "name": "", + "username": "msbreviews", + "avatar_path": "/https://secure.gravatar.com/avatar/992eef352126a53d7e141bf9e8707576.jpg", + "rating": 7.0 + }, + "content": "If you enjoy reading my Spoiler-Free reviews, please follow my blog @\r\nhttps://www.msbreviews.com\r\n\r\nAfter years of outstanding effort from passionate fans, Warner Bros. finally decided to give Zack Snyder the opportunity to finish his movie on his own terms. 2017's Justice League went through massive production issues - explained in my review of said film - and despite years of extremely tiresome, toxic discourse on social media, the famous Snyder Cut got a controversy-inducing budget to complete an undoubtedly unfinished cut. A crucial disclaimer: you'll see countless reviews based on wholly different approaches. Some people will review it as a standalone, regular movie, while others will look at it as an extended/alternate cut of a film previously released. I'm part of the latter group of reviewers.\r\n\r\nI find it a bit unfair to criticize pacing issues or an overlong runtime when the purpose of this cut is precisely to show everything Snyder had in his hand. Director/Extended/Ultimate Cut, call it what you feel it's appropriate, but it's a four-hour movie, so many scenes will inevitably drag or feel unnecessary and irrelevant. The narrative is fundamentally the same, which means the audience knows what's coming from a general perspective. Still, I'm reviewing this version mostly on its own merits, but without forgetting that it's not a regular theatrical film and that it unquestionably builds upon what was already released.\r\n\r\nWithout getting into spoilers, I do have to write this: the heavy marketing was incredibly misleading, and I don't doubt for a second that many fans will feel disappointed regarding certain story points and particular characters. The whole \"it's a totally distinct movie\" or \"Joss Whedon only used 10% of Snyder's footage\" were nothing more than false publicity for a cut that honestly didn't need it. Out of the 119 minutes of the 2017's version, probably around 80/90 minutes are also in the Snyder Cut, which will be surprising for people who expected something entirely unique. The base of the narrative is identical, most scenes are just extended versions of the original, but there are a couple of significant new changes that ultimately make Zack Snyder's Justice League better than its \"predecessor\".\r\n\r\nThe most impactful modification that drastically changes the emotional core of the film is about Cyborg. Ray Fisher's character goes from barely having any remotely significant screentime in 2017 to being the heart and soul of the movie. From his backstory to the development across the runtime, Cyborg is undoubtedly the superhero that gains the most with this cut, leaving as a complete, compelling character who I genuinely cared for. On the other hand, Aquaman and The Flash receive similar introductory scenes with Batman, barely getting any sort of new individual growth besides more action sequences. However, once the League is assembled, the character interactions increase, improving their team spirit and deeply elevating the \"Us United\" storyline.\r\n\r\nThe humor and tone remain lighter than in other Snyder films, clearly something that the filmmaker always had in mind for his version (Whedon only added a couple of more jokes since most of them are present in this cut). The intimidating runtime does negatively affect the overall pacing, but the longer build-ups and extensive dialogue scenes make the full movie much more cohesive and coherent. Compared to the original's abrupt, awful editing work, the Snyder Cut has a tremendously better flow, giving time for information to sink in and characters to get used to each other. I rather watch an overlong film with a well-built story than the complete contrary. Some color changes and tone adjustments also improve the movie's consistency.\r\n\r\nStory-wise, besides the fantastic arc given to Cyborg, there are a couple of changes that heavily affect either a particular character or a secondary storyline, but when it comes to the main narrative, it's more or less about the same. Every action sequence with pre-existent footage is visually improved and extended with scenes not seen before, but the new VFX are as hit-and-miss as Junkie XL's score. The latter mixes up so many different types of tracks and music that it genuinely becomes a tad confusing. While some scenes get an absolutely perfect, epic soundtrack, others receive weird, out-of-place music distracting the scene itself.\r\n\r\nThere's only one change I definitely dislike: the R rating brings horribly artificial, forced blood splashes and out-of-nowhere cursing that simply don't belong in the film. I know Snyder loves his gritty, bloody, gory action - as do I - but either the whole movie is consistent with this type of action, or some scenes will feel like they come from a wholly separate film. A few bloody sequences work well enough, but most just feel notably forced, while the cursing feels ridiculously out-of-character at points. It's by far the most incompatible aspect of the cut, but admittedly, one that doesn't heavily impact my opinion.\r\n\r\nA common issue I have with extended cuts is that these mostly add and rarely remove. Snyder Cut partially breaks that rule, removing some scenes from the 2017's version, supposedly only Whedon's footage (which some people wrongly believe to be almost the entire movie). While most of the decisions regarding this process are efficient, there's a couple of them that not only don't improve the respective storylines but actually make them less powerful than the theatrical film. For example, in Snyder Cut, the \"bringing Superman back\" arc lacks an important character's take on the situation, having in mind that character's past. It actually feels a bit out-of-character that the viewers don't get to see what that person thinks about a potentially devastating action.\r\n\r\nRegarding Steppenwolf, his design looks better than the terrible original, and his motivations are clearer, but unfortunately, he remains a generic CGI punching bag for our superheroes. His dynamic armor is packed with spikes, but it's really one of those designs with visual impact only since it has no effect whatsoever in battle. I can't get into spoilers about Darkseid or DeSaad, but I can safely write that these characters are nothing more than fan-service, just like Joker (Jared Leto). The ending is definitely the sequence that changes the most due to the addition of dozens of new/extended action scenes, and it does play out differently - though the conclusion is essentially the same - leaving the viewers with a menacing threat on the horizon.\r\n\r\nZack Snyder's Justice League is arguably a more cohesive, consistent, and emotionally compelling movie than the 2017's version. As expected, its four-hour runtime causes pacing issues and possesses dozens of unnecessary, irrelevant scenes, but criticizing these aspects in an admittedly non-theatrical cut is unfairly defeating its purpose. Despite most of the original Justice League being present in the Snyder Cut - something that might surprise a few fans - the main narrative is built and developed through a structure that flows tremendously better than the previous edition. Cyborg becoming the emotional core of the story and the increased character interactions are some of the best changes Zack Snyder and Chris Terrio did. The extended action sequences are more riveting, and pre-existent footage is definitely improved, but the new VFX are as erratic as Junkie XL's all-over-the-place score. The R-rating is the only straight-up negative aspect that damages the film with highly forced, fake-looking blood and rare yet cringe-worthy cursing. Highly anticipated characters and/or storylines are better described as unimpactful fan-service, but overall, most of the decisions made vastly improve upon what was already built. In the end, I sincerely expect a significant majority of the fandom to get their expectations fulfilled, and I hope that the DCEU continues with Snyder involved - just as long as the studios leave filmmakers to do their job without nonsensical restrictions.\r\n\r\nRating: B", + "created_at": "2021-03-15T16:20:41.130Z", + "id": "604f89596517d6006a209fa0", + "updated_at": "2021-03-15T16:20:41.130Z", + "url": "https://www.themoviedb.org/review/604f89596517d6006a209fa0" + }, + { + "author": "JPV852", + "author_details": { + "name": "", + "username": "JPV852", + "avatar_path": "/xNLOqXXVJf9m7WngUMLIMFsjKgh.jpg", + "rating": 7.0 + }, + "content": "Definitely an improvement over the Whedon version and as a DC fan, enjoyed plenty of the character moments (and the addition of MM) but the story did feel off even though the film is nearly four hours long, in addition the alternate timeline part at the end felt tacked on and unnecessary. The visual effects looked like something from a video game but I can forgive that since I'd imagine it'd cost an additional $100M to make it look cleaner and more realistic.\r\n\r\nGiven what Snyder had to work with and the fact in reality the studio should've been patient and given him and his family to mourn and re-group later, I am glad this did become a reality, shame that outside of maybe The Flash movie there won't be a follow-up with Darkseid. **3.5/5**", + "created_at": "2021-03-18T21:16:00.330Z", + "id": "6053c31042f19f003c9945cd", + "updated_at": "2021-03-18T21:16:00.330Z", + "url": "https://www.themoviedb.org/review/6053c31042f19f003c9945cd" + }, + { + "author": "sykobanana", + "author_details": { + "name": "", + "username": "sykobanana", + "avatar_path": "/j2KpvD880J95lqf3ct4u1MmWZhE.jpg", + "rating": 9.0 + }, + "content": "This is DC's best so far...easily. \r\nAlthough not flawless- there are a couple of scenes that didnt need to be included - this is the version that we needed to see. \r\nThere is so much more time given to the characters in this, the plot is better thought out and structured, and Steppenwolf is no longer a joke. There is actually an urgency in his battles with the JL now. And OMG, Darkseid and Desaad look and sound boss. \r\nThe recruitment is harder, the fight between the JL and Supes and then the JL and Steppenwolf is more brutal, and Superman's 'recovery' is better paced and makes more sense. \r\nThis also follows directly on from BVS and links directly into Aquaman. \r\nAnd the closing cover of Hallelujah is glorious (it near comes close to Jeff Buckley's angelic version)\r\n\r\nIt's flaws - Timing of release - I need to watch this in a cinema. FU WB for not giving Snyder the time to finish this as he envisioned. THIS is what we should have gotten, not the tripe that was Josstice League. This movie shows that Snyder was not just trying to catch up to Marvel, he was their own version of their stories and it should have been allowed to come to the end of the cycle. \r\nAlso, I was underwhelmed by the score and would have loved Zimmer to have returned to complete this. \r\nAnd there are a couple of initial set-up scenes that could have been let go or shortened and the gapping continuity error scene in the middle where the JL get introduced to Alfred after theyve already met him should have just been left out. \r\n\r\nBut we got what we asked for - the Snyder Cut in its entirety. \r\nAnd I am grateful for that...thankyou.\r\n\r\nNow, could we please create ZSJL2 so we can see the Injustice and Darkseid stories.", + "created_at": "2021-03-20T05:20:43.261Z", + "id": "6055862bfd4f800074b8e18f", + "updated_at": "2021-03-20T05:20:43.261Z", + "url": "https://www.themoviedb.org/review/6055862bfd4f800074b8e18f" + }, + { + "author": "Peter89Spencer", + "author_details": { + "name": "", + "username": "Peter89Spencer", + "avatar_path": "/https://secure.gravatar.com/avatar/dadb1b759a8516c815cdcc58abcefc85.jpg", + "rating": 10.0 + }, + "content": "I really, really REALLY wasn't expecting this - it felt like the extended edition of Lord of the Rings, if Zack Snyder directed it!\r\nNo, this was Zack Snyder's vision of Justice which we should've seen, if it wasn't for Joss Whedon hadn't pissed all over it. I mean, I understand why Snyder had to pull out the production, I really do, but Whedon could've had the decency to keep to heart on Snyder's work. Looking back, it was a total disrespect to the visionary director. This film...wow! It was truly and 100% amazed me.\r\n\r\nPlus, my best highlight was the voiceover of of Superman's dads Johnathan and Jor-El reminding him of who is he - it felt poignant.\r\n\r\nThat said, a couple of things I feel I must complain about this, just small things; 1) I noticed Mera had a change of accent. Probably an original idea, but it sounded funny especially soon after 2018 Aquaman was released. 2) Victor Stone's dad Silas sacrificed himself - not only was it upsetting but it changes everything for the future of DCEU films. I guess this means they;ll have to rewrite everything for that Cyborg movie!\r\n\r\nAt least we got the introduction of the Joker (Jared Leto reprising his role) as well as the (not so) surprising twist of the Martian Manhunter!\r\nNot to mention we finally got to see the appearance of Darkseid - it was all like a fusion of 300's Persian empire and LOTR's Sauron!\r\n\r\nIn the end, this was a true masterpiece from Zack Snyder, since Watchmen and Sucker Punch, and I am so pleased - no, grateful this was made. I thank not just the director himself for returning to this project but also the many fans who petitioned this director's cut to happen.\r\nFor once, we finally had something good out of all this chaos that was covid 19, hence why they chose the song Hallelujah for their Snyder cut trailer!\r\n\r\nThank you so much, Zack Snyder - you have always been my fav movie director.\r\nJoss Whedon, you are officially dead to me!", + "created_at": "2021-03-20T10:36:06.278Z", + "id": "6055d0169ae613006adfb910", + "updated_at": "2021-03-20T10:36:06.278Z", + "url": "https://www.themoviedb.org/review/6055d0169ae613006adfb910" + }, + { + "author": "Yassin_Raouf", + "author_details": { + "name": "", + "username": "Yassin_Raouf", + "avatar_path": null, + "rating": 10.0 + }, + "content": "10/10 this is absolutely fantastic, just 1 word, #RestoreTheSnyderVerse", + "created_at": "2021-03-24T16:38:24.885Z", + "id": "605b6b00d5ffcb005631ed1b", + "updated_at": "2021-03-25T19:28:23.052Z", + "url": "https://www.themoviedb.org/review/605b6b00d5ffcb005631ed1b" + } + ], + "total_pages": 1, + "total_results": 5 +} diff --git a/Movie/MovieTests/trending.json b/Movie/MovieTests/trending.json new file mode 100644 index 00000000..b987397e --- /dev/null +++ b/Movie/MovieTests/trending.json @@ -0,0 +1,427 @@ +{ + "page": 1, + "results": [ + { + "adult": false, + "backdrop_path": "/pcDc2WJAYGJTTvRSEIpRZwM3Ola.jpg", + "genre_ids": [ + 28, + 12, + 14, + 878 + ], + "id": 791373, + "original_language": "en", + "original_title": "Zack Snyder's Justice League", + "poster_path": "/tnAuB8q5vv7Ax9UAEje5Xi4BXik.jpg", + "video": false, + "title": "Zack Snyder's Justice League", + "vote_count": 4032, + "overview": "Determined to ensure Superman's ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.", + "release_date": "2021-03-18", + "vote_average": 8.7, + "popularity": 9701.638, + "media_type": "movie" + }, + { + "genre_ids": [ + 28, + 878 + ], + "title": "Godzilla vs. Kong", + "original_language": "en", + "original_title": "Godzilla vs. Kong", + "poster_path": "/pgqgaUx1cJb5oZQQ5v0tNARCeBp.jpg", + "video": false, + "vote_average": 7.1, + "overview": "In a time when monsters walk the Earth, humanity’s fight for its future sets Godzilla and Kong on a collision course that will see the two most powerful forces of nature on the planet collide in a spectacular battle for the ages.", + "release_date": "2021-03-24", + "vote_count": 155, + "id": 399566, + "adult": false, + "backdrop_path": "/iopYFB1b6Bh7FWZh3onQhph1sih.jpg", + "popularity": 9043.741, + "media_type": "movie" + }, + { + "adult": false, + "backdrop_path": "/tzm0qUH5fL01BtPazpksCtVaDcf.jpg", + "genre_ids": [ + 18 + ], + "id": 600354, + "original_language": "en", + "original_title": "The Father", + "poster_path": "/pr3bEQ517uMb5loLvjFQi8uLAsp.jpg", + "video": false, + "title": "The Father", + "vote_count": 100, + "overview": "A man refuses all assistance from his daughter as he ages and, as he tries to make sense of his changing circumstances, he begins to doubt his loved ones, his own mind and even the fabric of his reality.", + "release_date": "2020-12-23", + "vote_average": 8.1, + "popularity": 168.781, + "media_type": "movie" + }, + { + "video": false, + "vote_average": 6.1, + "title": "Bad Trip", + "vote_count": 42, + "overview": "Two pals embark on a road trip full of funny pranks that pull real people into mayhem.", + "adult": false, + "backdrop_path": "/mtwThSQ8AJ2hV5KkVsDETgZISAw.jpg", + "id": 578908, + "genre_ids": [ + 35 + ], + "release_date": "2021-03-26", + "original_language": "en", + "original_title": "Bad Trip", + "poster_path": "/A1Gy5HX3DKGaNW1Ay30NTIVJqJ6.jpg", + "popularity": 51.705, + "media_type": "movie" + }, + { + "overview": "Troubled teen Will Hawkins has a run-in with the law that puts him at an important crossroad: go to juvenile detention or attend a Christian summer camp. At first a fish-out-of-water, Will opens his heart, discovers love with a camp regular, and sense of belonging in the last place he expected to find it.", + "release_date": "2021-03-26", + "adult": false, + "backdrop_path": "/sA07ynKTNXW2UMMOgPfLmFmmjgm.jpg", + "genre_ids": [ + 18, + 10751, + 10402, + 10749 + ], + "vote_count": 60, + "original_language": "en", + "original_title": "A Week Away", + "poster_path": "/htTS07IvYv3rv57ftzNEprefwSq.jpg", + "id": 699102, + "video": false, + "vote_average": 7.4, + "title": "A Week Away", + "popularity": 207.523, + "media_type": "movie" + }, + { + "adult": false, + "backdrop_path": "/hJuDvwzS0SPlsE6MNFOpznQltDZ.jpg", + "genre_ids": [ + 16, + 12, + 14, + 10751, + 28 + ], + "id": 527774, + "original_language": "en", + "original_title": "Raya and the Last Dragon", + "poster_path": "/lPsD10PP4rgUGiGR4CCXA6iY0QQ.jpg", + "video": false, + "title": "Raya and the Last Dragon", + "vote_count": 1815, + "overview": "Long ago, in the fantasy world of Kumandra, humans and dragons lived together in harmony. But when an evil force threatened the land, the dragons sacrificed themselves to save humanity. Now, 500 years later, that same evil has returned and it’s up to a lone warrior, Raya, to track down the legendary last dragon to restore the fractured land and its divided people.", + "release_date": "2021-03-03", + "vote_average": 8.3, + "popularity": 3152.527, + "media_type": "movie" + }, + { + "genre_ids": [ + 16, + 35, + 14 + ], + "title": "Secret Magic Control Agency", + "original_language": "ru", + "original_title": "Ганзель, Гретель и Агентство Магии", + "poster_path": "/4ZSzEDVdxWVMVO4oZDvoodQOEfr.jpg", + "video": false, + "vote_average": 7.7, + "overview": "The Secret Magic Control Agency sends its two best agents, Hansel and Gretel, to fight against the witch of the Gingerbread House.", + "release_date": "2021-03-18", + "vote_count": 39, + "id": 797394, + "adult": false, + "backdrop_path": "/tzJZaglq1hR7RS35BKG68Xz7KY0.jpg", + "popularity": 48.528, + "media_type": "movie" + }, + { + "overview": "A botched store robbery places Wonder Woman in a global battle against a powerful and mysterious ancient force that puts her powers in jeopardy.", + "release_date": "2020-12-16", + "title": "Wonder Woman 1984", + "adult": false, + "backdrop_path": "/egg7KFi18TSQc1s24RMmR9i2zO6.jpg", + "genre_ids": [ + 14, + 28, + 12 + ], + "vote_count": 4533, + "id": 464052, + "original_title": "Wonder Woman 1984", + "poster_path": "/8UlWHLMpgZm9bx6QYh0NFoq67TZ.jpg", + "original_language": "en", + "video": false, + "vote_average": 6.8, + "popularity": 1894.648, + "media_type": "movie" + }, + { + "genre_ids": [ + 28, + 35, + 10751 + ], + "title": "Tom & Jerry", + "original_language": "en", + "original_title": "Tom & Jerry", + "poster_path": "/6KErczPBROQty7QoIsaa6wJYXZi.jpg", + "video": false, + "vote_average": 7.4, + "overview": "Tom the cat and Jerry the mouse get kicked out of their home and relocate to a fancy New York hotel, where a scrappy employee named Kayla will lose her job if she can’t evict Jerry before a high-class wedding at the hotel. Her solution? Hiring Tom to get rid of the pesky mouse.", + "release_date": "2021-02-11", + "vote_count": 1019, + "id": 587807, + "adult": false, + "backdrop_path": "/fev8UFNFFYsD5q7AcYS8LyTzqwl.jpg", + "popularity": 1931.099, + "media_type": "movie" + }, + { + "video": false, + "vote_average": 8.3, + "overview": "Joe Gardner is a middle school teacher with a love for jazz music. After a successful gig at the Half Note Club, he suddenly gets into an accident that separates his soul from his body and is transported to the You Seminar, a center in which souls develop and gain passions before being transported to a newborn child. Joe must enlist help from the other souls-in-training, like 22, a soul who has spent eons in the You Seminar, in order to get back to Earth.", + "release_date": "2020-12-25", + "title": "Soul", + "adult": false, + "backdrop_path": "/kf456ZqeC45XTvo6W9pW5clYKfQ.jpg", + "id": 508442, + "genre_ids": [ + 10751, + 16, + 35, + 18, + 10402, + 14 + ], + "vote_count": 5474, + "original_language": "en", + "original_title": "Soul", + "poster_path": "/hm58Jw4Lw8OIeECIq5qyPYhAeRJ.jpg", + "popularity": 805.443, + "media_type": "movie" + }, + { + "original_language": "en", + "original_title": "Monster Hunter", + "poster_path": "/1UCOF11QCw8kcqvce8LKOO6pimh.jpg", + "video": false, + "vote_average": 7.1, + "overview": "A portal transports Cpt. Artemis and an elite unit of soldiers to a strange world where powerful monsters rule with deadly ferocity. Faced with relentless danger, the team encounters a mysterious hunter who may be their only hope to find a way home.", + "release_date": "2020-12-03", + "id": 458576, + "vote_count": 1292, + "adult": false, + "backdrop_path": "/z8TvnEVRenMSTemxYZwLGqFofgF.jpg", + "title": "Monster Hunter", + "genre_ids": [ + 14, + 28, + 12 + ], + "popularity": 2021.063, + "media_type": "movie" + }, + { + "adult": false, + "backdrop_path": "/gzycjJWGw04DF6C7IYOA1F0cWhc.jpg", + "genre_ids": [ + 18, + 10749 + ], + "id": 694256, + "original_language": "it", + "original_title": "Sulla stessa onda", + "poster_path": "/j9O2WXJqF45ynkng4SAsZ1h0OCt.jpg", + "video": false, + "title": "Caught by a Wave", + "vote_count": 44, + "overview": "A summer fling born under the Sicilian sun quickly develops into a heartbreaking love story that forces a boy and girl to grow up too quickly.", + "release_date": "2021-03-25", + "vote_average": 6.8, + "popularity": 158.642, + "media_type": "movie" + }, + { + "overview": "Task Force X - aka the Suicide Squad - is dropped on the remote, enemy-infused island of Corto Maltese to find and destroy Jotunheim, a Nazi-era prison and laboratory.", + "release_date": "2021-07-30", + "adult": false, + "backdrop_path": "/7h0ZX8MalKLQeWaAhsnpBX6TKGA.jpg", + "genre_ids": [ + 28, + 12, + 14, + 80 + ], + "vote_count": 0, + "original_language": "en", + "original_title": "The Suicide Squad", + "poster_path": "/aQjO0ifNY8kv8ZA6U61wyNh5sGg.jpg", + "title": "The Suicide Squad", + "video": false, + "vote_average": 0.0, + "id": 436969, + "popularity": 125.121, + "media_type": "movie" + }, + { + "video": false, + "vote_average": 8.3, + "title": "Avengers: Endgame", + "vote_count": 17650, + "overview": "After the devastating events of Avengers: Infinity War, the universe is in ruins due to the efforts of the Mad Titan, Thanos. With the help of remaining allies, the Avengers must assemble once more in order to undo Thanos' actions and restore order to the universe once and for all, no matter what consequences may be in store.", + "adult": false, + "backdrop_path": "/7RyHsO4yDXtBv1zUU3mTpHeQ0d5.jpg", + "id": 299534, + "genre_ids": [ + 12, + 878, + 28 + ], + "release_date": "2019-04-24", + "original_language": "en", + "original_title": "Avengers: Endgame", + "poster_path": "/ulzhLuWrPK07P1YkdWQLZnQh1JL.jpg", + "popularity": 355.688, + "media_type": "movie" + }, + { + "adult": false, + "backdrop_path": "/vfuzELmhBjBTswXj2Vqxnu5ge4g.jpg", + "genre_ids": [ + 53, + 80 + ], + "id": 602269, + "original_language": "en", + "original_title": "The Little Things", + "poster_path": "/c7VlGCCgM9GZivKSzBgzuOVxQn7.jpg", + "video": false, + "title": "The Little Things", + "vote_count": 664, + "overview": "Deputy Sheriff Joe \"Deke\" Deacon joins forces with Sgt. Jim Baxter to search for a serial killer who's terrorizing Los Angeles. As they track the culprit, Baxter is unaware that the investigation is dredging up echoes of Deke's past, uncovering disturbing secrets that could threaten more than his case.", + "release_date": "2021-01-28", + "vote_average": 6.4, + "popularity": 1015.758, + "media_type": "movie" + }, + { + "overview": "Following the loss of their son, a retired sheriff and his wife leave their Montana ranch to rescue their young grandson from the clutches of a dangerous family living off the grid in the Dakotas.", + "release_date": "2020-11-05", + "adult": false, + "backdrop_path": "/abCn6fJjCPEZE3Q2dCmfidMxrEG.jpg", + "genre_ids": [ + 18, + 53, + 80 + ], + "vote_count": 212, + "original_language": "en", + "original_title": "Let Him Go", + "poster_path": "/EsLZoT8oHhQlGd1QpdbnvnwTzO.jpg", + "id": 596161, + "video": false, + "vote_average": 7.1, + "title": "Let Him Go", + "popularity": 49.928, + "media_type": "movie" + }, + { + "original_language": "en", + "original_title": "Godzilla", + "poster_path": "/iBZhbCVhLpyxAfW1B8ePUxjScrx.jpg", + "video": false, + "vote_average": 6.2, + "overview": "Ford Brody, a Navy bomb expert, has just reunited with his family in San Francisco when he is forced to go to Japan to help his estranged father, Joe. Soon, both men are swept up in an escalating crisis when an ancient alpha predator arises from the sea to combat malevolent adversaries that threaten the survival of humanity. The creatures leave colossal destruction in their wake, as they make their way toward their final battleground: San Francisco.", + "release_date": "2014-05-14", + "id": 124905, + "vote_count": 6745, + "adult": false, + "backdrop_path": "/zCjZfevPFBbOh2SAx2syIBHSqEI.jpg", + "title": "Godzilla", + "genre_ids": [ + 28, + 18, + 878 + ], + "popularity": 558.563, + "media_type": "movie" + }, + { + "overview": "Tina Turner overcame impossible odds to become one of the first female African American artists to reach a mainstream international audience. Her road to superstardom is an undeniable story of triumph over adversity. It’s the ultimate story of survival – and an inspirational story of our times.", + "release_date": "2021-03-02", + "title": "TINA", + "adult": false, + "backdrop_path": "/rv3bNtqYpARfJNPFctMeZrxq6bL.jpg", + "genre_ids": [ + 99, + 10402 + ], + "vote_count": 5, + "id": 773736, + "original_title": "TINA", + "poster_path": "/bL2FNPhiPqDyirzU3rfaXDiWRXs.jpg", + "original_language": "en", + "video": false, + "vote_average": 8.4, + "popularity": 29.596, + "media_type": "movie" + }, + { + "overview": "Following the abduction of her daughter, Zara does all she can to get her back. However, when she discovers that she has been targeted by crime boss Patrick, who she has previous dealings with, Zara realises that her troubled past will be used against her. Once Zara has a heart-to-heart with her husband, Brian, the pair work together to settle old scores and ensure the safety of their daughter.", + "release_date": "2021-11-06", + "adult": false, + "backdrop_path": "/tipRMVAH00gEvIhaWYKjkHMKDVJ.jpg", + "genre_ids": [ + 28, + 53 + ], + "vote_count": 0, + "original_language": "en", + "original_title": "Take Back", + "poster_path": "/16JXKkATNiSKouOhxyg9lkHBag9.jpg", + "title": "Take Back", + "video": false, + "vote_average": 0.0, + "id": 695282, + "popularity": 27.738, + "media_type": "movie" + }, + { + "original_language": "it", + "original_title": "Pinocchio", + "poster_path": "/lzqJcPaZA9G8C6eS4Hch475Ng3A.jpg", + "video": false, + "vote_average": 6.7, + "overview": "In this live-action adaptation of the beloved fairytale, old woodcarver Geppetto fashions a wooden puppet, Pinocchio, who magically comes to life. Pinocchio longs for adventure and is easily led astray, encountering magical beasts, fantastical spectacles, while making friends and foes along his journey. However, his dream is to become a real boy, which can only come true if he finally changes his ways.", + "release_date": "2019-12-19", + "id": 413518, + "vote_count": 949, + "adult": false, + "backdrop_path": "/AdqOBPw4PdtzOcfEuQuZ8MNeTKb.jpg", + "title": "Pinocchio", + "genre_ids": [ + 14, + 10751, + 12, + 18 + ], + "popularity": 89.292, + "media_type": "movie" + } + ], + "total_pages": 1000, + "total_results": 20000 +} \ No newline at end of file diff --git a/Movie/Podfile b/Movie/Podfile new file mode 100644 index 00000000..80c4e4ce --- /dev/null +++ b/Movie/Podfile @@ -0,0 +1,21 @@ +# Uncomment the next line to define a global platform for your project +platform :ios, '14.4' + +target 'Movie' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + + pod 'RxSwift' + pod 'RxCocoa' + pod 'RxDataSources' + pod 'Nuke' + + target 'MovieTests' do + inherit! :search_paths + pod 'RxSwift' + pod 'RxCocoa' + pod 'RxDataSources' + pod 'RxTest' + end + +end diff --git a/Movie/Podfile.lock b/Movie/Podfile.lock new file mode 100644 index 00000000..42166102 --- /dev/null +++ b/Movie/Podfile.lock @@ -0,0 +1,45 @@ +PODS: + - Differentiator (5.0.0) + - Nuke (9.4.1) + - RxCocoa (6.1.0): + - RxRelay (= 6.1.0) + - RxSwift (= 6.1.0) + - RxDataSources (5.0.0): + - Differentiator (~> 5.0) + - RxCocoa (~> 6.0) + - RxSwift (~> 6.0) + - RxRelay (6.1.0): + - RxSwift (= 6.1.0) + - RxSwift (6.1.0) + - RxTest (6.1.0): + - RxSwift (= 6.1.0) + +DEPENDENCIES: + - Nuke + - RxCocoa + - RxDataSources + - RxSwift + - RxTest + +SPEC REPOS: + trunk: + - Differentiator + - Nuke + - RxCocoa + - RxDataSources + - RxRelay + - RxSwift + - RxTest + +SPEC CHECKSUMS: + Differentiator: e8497ceab83c1b10ca233716d547b9af21b9344d + Nuke: ca782d1a417904db70f2c65d6feb2329a2679491 + RxCocoa: 5c51f02d562cbd94629f6c26cf0c80fe4ab8d343 + RxDataSources: aa47cc1ed6c500fa0dfecac5c979b723542d79cf + RxRelay: 483e1a19fad961b41f0b0c0bee506f46c1ae14fe + RxSwift: a834e5c538e89eca0cae86f403f4fbf0336786ce + RxTest: 9d26616eaff4a7e75f4ecae9b2c24840f2e8218d + +PODFILE CHECKSUM: 416b0a8c6384ac307e4df5d46d3817b3e50b889f + +COCOAPODS: 1.10.1 diff --git a/README.md b/README.md index 8bb0c38d..87a15248 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,33 @@ -# Work sample - Application developer - -## Assignment - -- Build an awesome movie app that shows popular and high rated movies. -- Code it for the platform (Android, iOS, web) you applied for or the one you prefer. - -## Requirements - -- Use an open https://developers.themoviedb.org/open source API. Please read the API [Authentication section](https://developers.themoviedb.org/3/getting-started/authentication) to get started. -- Discover most popular and highly rated movies. -- Display the movies with creative look and feel of an app to meet design guidelines for your platform (Material Design etc.). -- Launch a detail screen whenever a particular movie is selected. - -## Examples of bonus features - -- Allow user to save a favorite movie for offline access. -- Allow user to read movie reviews. - -## We expect you to - -- Write clean code. -- Create a responsive design. -- Handle error cases. -- Use the latest libraries and technologies. -- Tested code is a big plus. - -### User experience - -The features of the app might be few, but we expect you to deliver a solution with a high user experience. Imagine this application to be used by real users, with real needs. Make it interesting, fun and intuitive to use. And of course you are allowed to extend your applications functionality. - -### Code - -We expect that the code is of high quality and under source control. Expect the solution to be continuously worked on by other developers and should therefore be easy to understand, adjust and extend. True beauty starts on the inside! - -## Delivery - -Fork the repository, code in your fork and make a pull request when done. A nice commit history describing your work is preferred over squashing it into one commit. -Also send us an e-mail to let us know! - -### Good luck! - ---- - +# Movie App + +
+
+ + +
+
+ +# Features +- [x] Main View. Shows the list of the movies. Can be filtered by popular, trending, top rated. +- [x] Detail View, shows detailed info about a specific movie. +- [x] Clean Architecture (Subjectively) +- [x] Error Handling +- [x] Unit Tests (RxTest) for main vm. +## Bonus Features +- [x] Pagination (infinite scrolling) +- [x] Pull to Refresh + + +# Technical Overview + + * Generally, the architecture is inspired from pointfree.co creators, specifically the style used here: https://github.com/kickstarter/ios-oss. + * Swift\RxSwift. While Combine+SwiftUI could have been used and I am comfortable with those technologies as well, I have written more code in RxSwift thus given the limited time I chose RxSwift. Plain Swift (no reactive stuff) works as well. + * Plain navigation. No Coordinators given there are very few views. + * MVVM + Redux. Redux is used for more complex state management, in our case pagination. + * Expandable and highly customizable networking layer. New endpoints (supporting search, via GET /search/movie for example) can be added in less than 100 lines of code. Please check MovieApiProvider Enum as an example. No dependencies were used for this. Some of the code was reused from my older projects. The endpoint for fetching reviews is as well ready to use. + * Dependency Injection. Usually I am using a service-locator for this (such as Resolver: https://github.com/hmlongco/Resolver) however for our app it is an overkill. + * RxDataSources for effective collectionView diffing. + * UI is a mix of storyboards&code. Generally code is prioritized(As in, text is not hardcoded so it can be further localized etc.). For larger scale projects, only code. + +## How to run: +cd into Movie folder, run pod install, open Movie.xcworkspace. \ No newline at end of file diff --git a/screenshots/1.PNG b/screenshots/1.PNG new file mode 100644 index 00000000..83b53aad Binary files /dev/null and b/screenshots/1.PNG differ diff --git a/screenshots/2.PNG b/screenshots/2.PNG new file mode 100644 index 00000000..2a798546 Binary files /dev/null and b/screenshots/2.PNG differ