diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ba1ff083 --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.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 +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.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/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# 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 + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..099ffaae --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,39 @@ +disabled_rules: # rule identifiers to exclude from running + + - trailing_whitespace + - function_parameter_count + - colon + - comma + - comment_spacing + +excluded: +- Pods +- Package.swift + + + +line_length: + warning: 300 + error: 400 + +identifier_name: + min_length: 1 + max_length: 50 + +type_name: + min_length: 2 + max_length: + warning: 50 + +nesting: + type_level: 3 + +type_body_length: + warning: 300 + +force_try: warning +force_cast: warning + +reporter: "xcode" + + diff --git a/DarsayTechTest.xcodeproj/project.pbxproj b/DarsayTechTest.xcodeproj/project.pbxproj new file mode 100644 index 00000000..a6e12391 --- /dev/null +++ b/DarsayTechTest.xcodeproj/project.pbxproj @@ -0,0 +1,968 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + EE1A9EAC298519F600E4F9EF /* EndPointTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EAB298519F600E4F9EF /* EndPointTarget.swift */; }; + EE1A9EAE29851A1200E4F9EF /* NetworkExecuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EAD29851A1200E4F9EF /* NetworkExecuter.swift */; }; + EE1A9EB029851A6200E4F9EF /* MovieServiceEndPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EAF29851A6200E4F9EF /* MovieServiceEndPoint.swift */; }; + EE1A9EB229851A8100E4F9EF /* MovieNetworkAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EB129851A8100E4F9EF /* MovieNetworkAPI.swift */; }; + EE1A9EB429851A8D00E4F9EF /* MovieNetworkAPIManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EB329851A8D00E4F9EF /* MovieNetworkAPIManager.swift */; }; + EE1A9EB729851B3800E4F9EF /* ResultList.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EB629851B3800E4F9EF /* ResultList.swift */; }; + EE1A9EB929851B4E00E4F9EF /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EB829851B4E00E4F9EF /* Movie.swift */; }; + EE1A9EBB29851B6900E4F9EF /* Review.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EBA29851B6900E4F9EF /* Review.swift */; }; + EE1A9EBD29851CAB00E4F9EF /* MockService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EBC29851CAB00E4F9EF /* MockService.swift */; }; + EE1A9EBF29851D7900E4F9EF /* Publisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EBE29851D7900E4F9EF /* Publisher.swift */; }; + EE1A9EC129851D9100E4F9EF /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EC029851D9100E4F9EF /* URL.swift */; }; + EE1A9EC329851E3000E4F9EF /* Constant.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EC229851E3000E4F9EF /* Constant.swift */; }; + EE1A9EC529851E5700E4F9EF /* Layout.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EC429851E5700E4F9EF /* Layout.swift */; }; + EE1A9EC729851E8300E4F9EF /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EC629851E8300E4F9EF /* Image.swift */; }; + EE1A9ECA29851F9800E4F9EF /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EC929851F9800E4F9EF /* ImageLoader.swift */; }; + EE1A9ECC2985206000E4F9EF /* BaseProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9ECB2985206000E4F9EF /* BaseProtocols.swift */; }; + EE1A9ECE298520B400E4F9EF /* PageSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9ECD298520B400E4F9EF /* PageSection.swift */; }; + EE1A9ED4298520D600E4F9EF /* MovieListRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9ECF298520D600E4F9EF /* MovieListRouter.swift */; }; + EE1A9ED5298520D600E4F9EF /* MovieListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9ED0298520D600E4F9EF /* MovieListViewController.swift */; }; + EE1A9ED6298520D600E4F9EF /* MovieListBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9ED1298520D600E4F9EF /* MovieListBuilder.swift */; }; + EE1A9ED7298520D600E4F9EF /* MovieListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9ED2298520D600E4F9EF /* MovieListModel.swift */; }; + EE1A9ED8298520D600E4F9EF /* MovieListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9ED3298520D600E4F9EF /* MovieListViewModel.swift */; }; + EE1A9EDC2985212300E4F9EF /* MovieBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EDB2985212300E4F9EF /* MovieBannerView.swift */; }; + EE1A9EE5298522EB00E4F9EF /* MovieDetailRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EE0298522EB00E4F9EF /* MovieDetailRouter.swift */; }; + EE1A9EE6298522EB00E4F9EF /* MovieDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EE1298522EB00E4F9EF /* MovieDetailViewController.swift */; }; + EE1A9EE7298522EB00E4F9EF /* MovieDetailBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EE2298522EB00E4F9EF /* MovieDetailBuilder.swift */; }; + EE1A9EE8298522EB00E4F9EF /* MovieDetailModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EE3298522EB00E4F9EF /* MovieDetailModel.swift */; }; + EE1A9EE9298522EB00E4F9EF /* MovieDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EE4298522EB00E4F9EF /* MovieDetailViewModel.swift */; }; + EE1A9EEB298523C100E4F9EF /* ReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EEA298523C100E4F9EF /* ReviewView.swift */; }; + EE1A9EED298529A300E4F9EF /* LoadableJSONTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EEC298529A300E4F9EF /* LoadableJSONTest.swift */; }; + EE1A9EEF29852A7300E4F9EF /* ExecuterTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EEE29852A7300E4F9EF /* ExecuterTest.swift */; }; + EE1A9EF129852AD200E4F9EF /* MockExecuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE1A9EF029852AD200E4F9EF /* MockExecuter.swift */; }; + EE2293C12985855E0077B40D /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = EE2293C02985855E0077B40D /* Localizable.stringsdict */; }; + EE2293C32985859F0077B40D /* LocalizeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293C22985859F0077B40D /* LocalizeHelper.swift */; }; + EE2293C5298594AA0077B40D /* LoggerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293C4298594AA0077B40D /* LoggerHelper.swift */; }; + EE2293C7298595F20077B40D /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293C6298595F20077B40D /* Data.swift */; }; + EE2293C929859F100077B40D /* StorageHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293C829859F100077B40D /* StorageHelper.swift */; }; + EE2293CB2985AA330077B40D /* StorageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293CA2985AA330077B40D /* StorageTest.swift */; }; + EE2293D4298666EB0077B40D /* FavoriteListRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293CF298666EB0077B40D /* FavoriteListRouter.swift */; }; + EE2293D5298666EB0077B40D /* FavoriteListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293D0298666EB0077B40D /* FavoriteListViewController.swift */; }; + EE2293D6298666EB0077B40D /* FavoriteListBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293D1298666EB0077B40D /* FavoriteListBuilder.swift */; }; + EE2293D7298666EB0077B40D /* FavoriteListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293D2298666EB0077B40D /* FavoriteListModel.swift */; }; + EE2293D8298666EB0077B40D /* FavoriteListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293D3298666EB0077B40D /* FavoriteListViewModel.swift */; }; + EE2293DE2986CF070077B40D /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293DD2986CF070077B40D /* UIColor.swift */; }; + EE2293E02986DBC00077B40D /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293DF2986DBC00077B40D /* UIImage.swift */; }; + EE2293E22986E1EE0077B40D /* ViewModelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293E12986E1EE0077B40D /* ViewModelTest.swift */; }; + EE2293E42986E6EA0077B40D /* XCTest+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293E32986E6EA0077B40D /* XCTest+Extension.swift */; }; + EE2293E629870F1B0077B40D /* SupplementaryHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2293E529870F1B0077B40D /* SupplementaryHeaderView.swift */; }; + EEB43ADC29850E4000DF5CAD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB43ADB29850E4000DF5CAD /* AppDelegate.swift */; }; + EEB43ADE29850E4000DF5CAD /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB43ADD29850E4000DF5CAD /* SceneDelegate.swift */; }; + EEB43AE529850E4100DF5CAD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EEB43AE429850E4100DF5CAD /* Assets.xcassets */; }; + EEB43AE829850E4100DF5CAD /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = EEB43AE629850E4100DF5CAD /* LaunchScreen.storyboard */; }; + EEB43AF329850E4100DF5CAD /* ViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB43AF229850E4100DF5CAD /* ViewControllerTest.swift */; }; + EEB43AFD29850E4100DF5CAD /* DarsayTechTestUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB43AFC29850E4100DF5CAD /* DarsayTechTestUITests.swift */; }; + EEB43AFF29850E4100DF5CAD /* DarsayTechTestUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB43AFE29850E4100DF5CAD /* DarsayTechTestUITestsLaunchTests.swift */; }; + EEB43B182985116100DF5CAD /* review_json.json in Resources */ = {isa = PBXBuildFile; fileRef = EEB43B172985116100DF5CAD /* review_json.json */; }; + EEB43B1A2985117F00DF5CAD /* top_rated_json.json in Resources */ = {isa = PBXBuildFile; fileRef = EEB43B192985117F00DF5CAD /* top_rated_json.json */; }; + EEB43B1C2985119A00DF5CAD /* most_popular_json.json in Resources */ = {isa = PBXBuildFile; fileRef = EEB43B1B2985119A00DF5CAD /* most_popular_json.json */; }; + EEB43B1E298511B600DF5CAD /* detail_json.json in Resources */ = {isa = PBXBuildFile; fileRef = EEB43B1D298511B600DF5CAD /* detail_json.json */; }; + EEB43B20298511E800DF5CAD /* JSONLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB43B1F298511E800DF5CAD /* JSONLoader.swift */; }; + EEB43B222985126D00DF5CAD /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB43B212985126D00DF5CAD /* Error.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + EEB43AEF29850E4100DF5CAD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EEB43AD029850E4000DF5CAD /* Project object */; + proxyType = 1; + remoteGlobalIDString = EEB43AD729850E4000DF5CAD; + remoteInfo = DarsayTechTest; + }; + EEB43AF929850E4100DF5CAD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = EEB43AD029850E4000DF5CAD /* Project object */; + proxyType = 1; + remoteGlobalIDString = EEB43AD729850E4000DF5CAD; + remoteInfo = DarsayTechTest; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + EE1A9EAB298519F600E4F9EF /* EndPointTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndPointTarget.swift; sourceTree = ""; }; + EE1A9EAD29851A1200E4F9EF /* NetworkExecuter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkExecuter.swift; sourceTree = ""; }; + EE1A9EAF29851A6200E4F9EF /* MovieServiceEndPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieServiceEndPoint.swift; sourceTree = ""; }; + EE1A9EB129851A8100E4F9EF /* MovieNetworkAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieNetworkAPI.swift; sourceTree = ""; }; + EE1A9EB329851A8D00E4F9EF /* MovieNetworkAPIManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieNetworkAPIManager.swift; sourceTree = ""; }; + EE1A9EB629851B3800E4F9EF /* ResultList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultList.swift; sourceTree = ""; }; + EE1A9EB829851B4E00E4F9EF /* Movie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Movie.swift; sourceTree = ""; }; + EE1A9EBA29851B6900E4F9EF /* Review.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Review.swift; sourceTree = ""; }; + EE1A9EBC29851CAB00E4F9EF /* MockService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockService.swift; sourceTree = ""; }; + EE1A9EBE29851D7900E4F9EF /* Publisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publisher.swift; sourceTree = ""; }; + EE1A9EC029851D9100E4F9EF /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; + EE1A9EC229851E3000E4F9EF /* Constant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constant.swift; sourceTree = ""; }; + EE1A9EC429851E5700E4F9EF /* Layout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Layout.swift; sourceTree = ""; }; + EE1A9EC629851E8300E4F9EF /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; + EE1A9EC929851F9800E4F9EF /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; + EE1A9ECB2985206000E4F9EF /* BaseProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseProtocols.swift; sourceTree = ""; }; + EE1A9ECD298520B400E4F9EF /* PageSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSection.swift; sourceTree = ""; }; + EE1A9ECF298520D600E4F9EF /* MovieListRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListRouter.swift; sourceTree = ""; }; + EE1A9ED0298520D600E4F9EF /* MovieListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListViewController.swift; sourceTree = ""; }; + EE1A9ED1298520D600E4F9EF /* MovieListBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListBuilder.swift; sourceTree = ""; }; + EE1A9ED2298520D600E4F9EF /* MovieListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListModel.swift; sourceTree = ""; }; + EE1A9ED3298520D600E4F9EF /* MovieListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListViewModel.swift; sourceTree = ""; }; + EE1A9EDB2985212300E4F9EF /* MovieBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieBannerView.swift; sourceTree = ""; }; + EE1A9EE0298522EB00E4F9EF /* MovieDetailRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieDetailRouter.swift; sourceTree = ""; }; + EE1A9EE1298522EB00E4F9EF /* MovieDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieDetailViewController.swift; sourceTree = ""; }; + EE1A9EE2298522EB00E4F9EF /* MovieDetailBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieDetailBuilder.swift; sourceTree = ""; }; + EE1A9EE3298522EB00E4F9EF /* MovieDetailModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieDetailModel.swift; sourceTree = ""; }; + EE1A9EE4298522EB00E4F9EF /* MovieDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieDetailViewModel.swift; sourceTree = ""; }; + EE1A9EEA298523C100E4F9EF /* ReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewView.swift; sourceTree = ""; }; + EE1A9EEC298529A300E4F9EF /* LoadableJSONTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableJSONTest.swift; sourceTree = ""; }; + EE1A9EEE29852A7300E4F9EF /* ExecuterTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecuterTest.swift; sourceTree = ""; }; + EE1A9EF029852AD200E4F9EF /* MockExecuter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockExecuter.swift; sourceTree = ""; }; + EE2293C02985855E0077B40D /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = ""; }; + EE2293C22985859F0077B40D /* LocalizeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizeHelper.swift; sourceTree = ""; }; + EE2293C4298594AA0077B40D /* LoggerHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerHelper.swift; sourceTree = ""; }; + EE2293C6298595F20077B40D /* Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; + EE2293C829859F100077B40D /* StorageHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageHelper.swift; sourceTree = ""; }; + EE2293CA2985AA330077B40D /* StorageTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageTest.swift; sourceTree = ""; }; + EE2293CF298666EB0077B40D /* FavoriteListRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteListRouter.swift; sourceTree = ""; }; + EE2293D0298666EB0077B40D /* FavoriteListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteListViewController.swift; sourceTree = ""; }; + EE2293D1298666EB0077B40D /* FavoriteListBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteListBuilder.swift; sourceTree = ""; }; + EE2293D2298666EB0077B40D /* FavoriteListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteListModel.swift; sourceTree = ""; }; + EE2293D3298666EB0077B40D /* FavoriteListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteListViewModel.swift; sourceTree = ""; }; + EE2293DD2986CF070077B40D /* UIColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; + EE2293DF2986DBC00077B40D /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; + EE2293E12986E1EE0077B40D /* ViewModelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelTest.swift; sourceTree = ""; }; + EE2293E32986E6EA0077B40D /* XCTest+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTest+Extension.swift"; sourceTree = ""; }; + EE2293E529870F1B0077B40D /* SupplementaryHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupplementaryHeaderView.swift; sourceTree = ""; }; + EEB43AD829850E4000DF5CAD /* DarsayTechTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DarsayTechTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EEB43ADB29850E4000DF5CAD /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + EEB43ADD29850E4000DF5CAD /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + EEB43AE429850E4100DF5CAD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + EEB43AE729850E4100DF5CAD /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + EEB43AE929850E4100DF5CAD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + EEB43AEE29850E4100DF5CAD /* DarsayTechTestTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DarsayTechTestTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EEB43AF229850E4100DF5CAD /* ViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerTest.swift; sourceTree = ""; }; + EEB43AF829850E4100DF5CAD /* DarsayTechTestUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DarsayTechTestUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EEB43AFC29850E4100DF5CAD /* DarsayTechTestUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarsayTechTestUITests.swift; sourceTree = ""; }; + EEB43AFE29850E4100DF5CAD /* DarsayTechTestUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DarsayTechTestUITestsLaunchTests.swift; sourceTree = ""; }; + EEB43B172985116100DF5CAD /* review_json.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = review_json.json; sourceTree = ""; }; + EEB43B192985117F00DF5CAD /* top_rated_json.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = top_rated_json.json; sourceTree = ""; }; + EEB43B1B2985119A00DF5CAD /* most_popular_json.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = most_popular_json.json; sourceTree = ""; }; + EEB43B1D298511B600DF5CAD /* detail_json.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = detail_json.json; sourceTree = ""; }; + EEB43B1F298511E800DF5CAD /* JSONLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONLoader.swift; sourceTree = ""; }; + EEB43B212985126D00DF5CAD /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + EEB43AD529850E4000DF5CAD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EEB43AEB29850E4100DF5CAD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EEB43AF529850E4100DF5CAD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + EE1A9EA8298519CF00E4F9EF /* Base */ = { + isa = PBXGroup; + children = ( + EE1A9EAB298519F600E4F9EF /* EndPointTarget.swift */, + EE1A9EAD29851A1200E4F9EF /* NetworkExecuter.swift */, + ); + path = Base; + sourceTree = ""; + }; + EE1A9EA9298519DC00E4F9EF /* Service */ = { + isa = PBXGroup; + children = ( + EE1A9EAF29851A6200E4F9EF /* MovieServiceEndPoint.swift */, + ); + path = Service; + sourceTree = ""; + }; + EE1A9EAA298519E200E4F9EF /* Manager */ = { + isa = PBXGroup; + children = ( + EE1A9EB129851A8100E4F9EF /* MovieNetworkAPI.swift */, + EE1A9EB329851A8D00E4F9EF /* MovieNetworkAPIManager.swift */, + ); + path = Manager; + sourceTree = ""; + }; + EE1A9EB529851ABB00E4F9EF /* Networking Models */ = { + isa = PBXGroup; + children = ( + EE1A9EB629851B3800E4F9EF /* ResultList.swift */, + EE1A9EB829851B4E00E4F9EF /* Movie.swift */, + EE1A9EBA29851B6900E4F9EF /* Review.swift */, + EE1A9EC629851E8300E4F9EF /* Image.swift */, + ); + path = "Networking Models"; + sourceTree = ""; + }; + EE1A9EC829851F8D00E4F9EF /* Helper */ = { + isa = PBXGroup; + children = ( + EE1A9EC929851F9800E4F9EF /* ImageLoader.swift */, + EE2293C22985859F0077B40D /* LocalizeHelper.swift */, + EE2293C4298594AA0077B40D /* LoggerHelper.swift */, + EE2293C829859F100077B40D /* StorageHelper.swift */, + ); + path = Helper; + sourceTree = ""; + }; + EE2293CE298666D60077B40D /* FavoriteList */ = { + isa = PBXGroup; + children = ( + EE2293D1298666EB0077B40D /* FavoriteListBuilder.swift */, + EE2293D2298666EB0077B40D /* FavoriteListModel.swift */, + EE2293D3298666EB0077B40D /* FavoriteListViewModel.swift */, + EE2293D0298666EB0077B40D /* FavoriteListViewController.swift */, + EE2293CF298666EB0077B40D /* FavoriteListRouter.swift */, + ); + path = FavoriteList; + sourceTree = ""; + }; + EEB43ACF29850E4000DF5CAD = { + isa = PBXGroup; + children = ( + EEB43ADA29850E4000DF5CAD /* DarsayTechTest */, + EEB43AF129850E4100DF5CAD /* DarsayTechTestTests */, + EEB43AFB29850E4100DF5CAD /* DarsayTechTestUITests */, + EEB43AD929850E4000DF5CAD /* Products */, + ); + sourceTree = ""; + }; + EEB43AD929850E4000DF5CAD /* Products */ = { + isa = PBXGroup; + children = ( + EEB43AD829850E4000DF5CAD /* DarsayTechTest.app */, + EEB43AEE29850E4100DF5CAD /* DarsayTechTestTests.xctest */, + EEB43AF829850E4100DF5CAD /* DarsayTechTestUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + EEB43ADA29850E4000DF5CAD /* DarsayTechTest */ = { + isa = PBXGroup; + children = ( + EEB43ADB29850E4000DF5CAD /* AppDelegate.swift */, + EEB43ADD29850E4000DF5CAD /* SceneDelegate.swift */, + EEB43B0B29850E9E00DF5CAD /* Scenes */, + EE1A9EC829851F8D00E4F9EF /* Helper */, + EEB43B0C29850EAC00DF5CAD /* Networking */, + EEB43B0D29850EB800DF5CAD /* Models */, + EEB43B0E29850EC200DF5CAD /* Extension */, + EEB43B1029850EFF00DF5CAD /* Constant */, + EEB43B0F29850ED900DF5CAD /* Resources */, + EEB43AE929850E4100DF5CAD /* Info.plist */, + ); + path = DarsayTechTest; + sourceTree = ""; + }; + EEB43AF129850E4100DF5CAD /* DarsayTechTestTests */ = { + isa = PBXGroup; + children = ( + EEB43B162985112000DF5CAD /* JSON */, + EEB43B1F298511E800DF5CAD /* JSONLoader.swift */, + EE1A9EBC29851CAB00E4F9EF /* MockService.swift */, + EE1A9EF029852AD200E4F9EF /* MockExecuter.swift */, + EE1A9EEC298529A300E4F9EF /* LoadableJSONTest.swift */, + EE1A9EEE29852A7300E4F9EF /* ExecuterTest.swift */, + EE2293E12986E1EE0077B40D /* ViewModelTest.swift */, + EEB43AF229850E4100DF5CAD /* ViewControllerTest.swift */, + EE2293CA2985AA330077B40D /* StorageTest.swift */, + EE2293E32986E6EA0077B40D /* XCTest+Extension.swift */, + ); + path = DarsayTechTestTests; + sourceTree = ""; + }; + EEB43AFB29850E4100DF5CAD /* DarsayTechTestUITests */ = { + isa = PBXGroup; + children = ( + EEB43AFC29850E4100DF5CAD /* DarsayTechTestUITests.swift */, + EEB43AFE29850E4100DF5CAD /* DarsayTechTestUITestsLaunchTests.swift */, + ); + path = DarsayTechTestUITests; + sourceTree = ""; + }; + EEB43B0B29850E9E00DF5CAD /* Scenes */ = { + isa = PBXGroup; + children = ( + EEB43B1129850F5F00DF5CAD /* Base */, + EEB43B1229850F6900DF5CAD /* MovieList */, + EEB43B1429850F8300DF5CAD /* MovieDetail */, + EE2293CE298666D60077B40D /* FavoriteList */, + ); + path = Scenes; + sourceTree = ""; + }; + EEB43B0C29850EAC00DF5CAD /* Networking */ = { + isa = PBXGroup; + children = ( + EE1A9EA8298519CF00E4F9EF /* Base */, + EE1A9EA9298519DC00E4F9EF /* Service */, + EE1A9EAA298519E200E4F9EF /* Manager */, + ); + path = Networking; + sourceTree = ""; + }; + EEB43B0D29850EB800DF5CAD /* Models */ = { + isa = PBXGroup; + children = ( + EE1A9EB529851ABB00E4F9EF /* Networking Models */, + EEB43B212985126D00DF5CAD /* Error.swift */, + EE1A9ECD298520B400E4F9EF /* PageSection.swift */, + ); + path = Models; + sourceTree = ""; + }; + EEB43B0E29850EC200DF5CAD /* Extension */ = { + isa = PBXGroup; + children = ( + EE1A9EBE29851D7900E4F9EF /* Publisher.swift */, + EE1A9EC029851D9100E4F9EF /* URL.swift */, + EE2293C6298595F20077B40D /* Data.swift */, + EE2293DD2986CF070077B40D /* UIColor.swift */, + EE2293DF2986DBC00077B40D /* UIImage.swift */, + ); + path = Extension; + sourceTree = ""; + }; + EEB43B0F29850ED900DF5CAD /* Resources */ = { + isa = PBXGroup; + children = ( + EEB43AE629850E4100DF5CAD /* LaunchScreen.storyboard */, + EEB43AE429850E4100DF5CAD /* Assets.xcassets */, + EE2293C02985855E0077B40D /* Localizable.stringsdict */, + ); + path = Resources; + sourceTree = ""; + }; + EEB43B1029850EFF00DF5CAD /* Constant */ = { + isa = PBXGroup; + children = ( + EE1A9EC229851E3000E4F9EF /* Constant.swift */, + EE1A9EC429851E5700E4F9EF /* Layout.swift */, + ); + path = Constant; + sourceTree = ""; + }; + EEB43B1129850F5F00DF5CAD /* Base */ = { + isa = PBXGroup; + children = ( + EE1A9ECB2985206000E4F9EF /* BaseProtocols.swift */, + ); + path = Base; + sourceTree = ""; + }; + EEB43B1229850F6900DF5CAD /* MovieList */ = { + isa = PBXGroup; + children = ( + EEB43B1329850F7D00DF5CAD /* Views */, + EE1A9ED1298520D600E4F9EF /* MovieListBuilder.swift */, + EE1A9ED2298520D600E4F9EF /* MovieListModel.swift */, + EE1A9ED3298520D600E4F9EF /* MovieListViewModel.swift */, + EE1A9ED0298520D600E4F9EF /* MovieListViewController.swift */, + EE1A9ECF298520D600E4F9EF /* MovieListRouter.swift */, + ); + path = MovieList; + sourceTree = ""; + }; + EEB43B1329850F7D00DF5CAD /* Views */ = { + isa = PBXGroup; + children = ( + EE1A9EDB2985212300E4F9EF /* MovieBannerView.swift */, + EE2293E529870F1B0077B40D /* SupplementaryHeaderView.swift */, + ); + path = Views; + sourceTree = ""; + }; + EEB43B1429850F8300DF5CAD /* MovieDetail */ = { + isa = PBXGroup; + children = ( + EEB43B1529850F8B00DF5CAD /* Views */, + EE1A9EE2298522EB00E4F9EF /* MovieDetailBuilder.swift */, + EE1A9EE3298522EB00E4F9EF /* MovieDetailModel.swift */, + EE1A9EE4298522EB00E4F9EF /* MovieDetailViewModel.swift */, + EE1A9EE1298522EB00E4F9EF /* MovieDetailViewController.swift */, + EE1A9EE0298522EB00E4F9EF /* MovieDetailRouter.swift */, + ); + path = MovieDetail; + sourceTree = ""; + }; + EEB43B1529850F8B00DF5CAD /* Views */ = { + isa = PBXGroup; + children = ( + EE1A9EEA298523C100E4F9EF /* ReviewView.swift */, + ); + path = Views; + sourceTree = ""; + }; + EEB43B162985112000DF5CAD /* JSON */ = { + isa = PBXGroup; + children = ( + EEB43B172985116100DF5CAD /* review_json.json */, + EEB43B192985117F00DF5CAD /* top_rated_json.json */, + EEB43B1B2985119A00DF5CAD /* most_popular_json.json */, + EEB43B1D298511B600DF5CAD /* detail_json.json */, + ); + path = JSON; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + EEB43AD729850E4000DF5CAD /* DarsayTechTest */ = { + isa = PBXNativeTarget; + buildConfigurationList = EEB43B0229850E4100DF5CAD /* Build configuration list for PBXNativeTarget "DarsayTechTest" */; + buildPhases = ( + EEB43AD429850E4000DF5CAD /* Sources */, + EEB43AD529850E4000DF5CAD /* Frameworks */, + EEB43AD629850E4000DF5CAD /* Resources */, + EEB43B232985179900DF5CAD /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DarsayTechTest; + packageProductDependencies = ( + ); + productName = DarsayTechTest; + productReference = EEB43AD829850E4000DF5CAD /* DarsayTechTest.app */; + productType = "com.apple.product-type.application"; + }; + EEB43AED29850E4100DF5CAD /* DarsayTechTestTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EEB43B0529850E4100DF5CAD /* Build configuration list for PBXNativeTarget "DarsayTechTestTests" */; + buildPhases = ( + EEB43AEA29850E4100DF5CAD /* Sources */, + EEB43AEB29850E4100DF5CAD /* Frameworks */, + EEB43AEC29850E4100DF5CAD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EEB43AF029850E4100DF5CAD /* PBXTargetDependency */, + ); + name = DarsayTechTestTests; + productName = DarsayTechTestTests; + productReference = EEB43AEE29850E4100DF5CAD /* DarsayTechTestTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + EEB43AF729850E4100DF5CAD /* DarsayTechTestUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EEB43B0829850E4100DF5CAD /* Build configuration list for PBXNativeTarget "DarsayTechTestUITests" */; + buildPhases = ( + EEB43AF429850E4100DF5CAD /* Sources */, + EEB43AF529850E4100DF5CAD /* Frameworks */, + EEB43AF629850E4100DF5CAD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EEB43AFA29850E4100DF5CAD /* PBXTargetDependency */, + ); + name = DarsayTechTestUITests; + productName = DarsayTechTestUITests; + productReference = EEB43AF829850E4100DF5CAD /* DarsayTechTestUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + EEB43AD029850E4000DF5CAD /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1400; + LastUpgradeCheck = 1400; + TargetAttributes = { + EEB43AD729850E4000DF5CAD = { + CreatedOnToolsVersion = 14.0.1; + }; + EEB43AED29850E4100DF5CAD = { + CreatedOnToolsVersion = 14.0.1; + TestTargetID = EEB43AD729850E4000DF5CAD; + }; + EEB43AF729850E4100DF5CAD = { + CreatedOnToolsVersion = 14.0.1; + TestTargetID = EEB43AD729850E4000DF5CAD; + }; + }; + }; + buildConfigurationList = EEB43AD329850E4000DF5CAD /* Build configuration list for PBXProject "DarsayTechTest" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = EEB43ACF29850E4000DF5CAD; + packageReferences = ( + ); + productRefGroup = EEB43AD929850E4000DF5CAD /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + EEB43AD729850E4000DF5CAD /* DarsayTechTest */, + EEB43AED29850E4100DF5CAD /* DarsayTechTestTests */, + EEB43AF729850E4100DF5CAD /* DarsayTechTestUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + EEB43AD629850E4000DF5CAD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EEB43AE829850E4100DF5CAD /* LaunchScreen.storyboard in Resources */, + EEB43AE529850E4100DF5CAD /* Assets.xcassets in Resources */, + EE2293C12985855E0077B40D /* Localizable.stringsdict in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EEB43AEC29850E4100DF5CAD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EEB43B1E298511B600DF5CAD /* detail_json.json in Resources */, + EEB43B1A2985117F00DF5CAD /* top_rated_json.json in Resources */, + EEB43B182985116100DF5CAD /* review_json.json in Resources */, + EEB43B1C2985119A00DF5CAD /* most_popular_json.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EEB43AF629850E4100DF5CAD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + EEB43B232985179900DF5CAD /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "./Scripts/swiftlint.sh\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + EEB43AD429850E4000DF5CAD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EE1A9EBF29851D7900E4F9EF /* Publisher.swift in Sources */, + EE2293DE2986CF070077B40D /* UIColor.swift in Sources */, + EEB43B222985126D00DF5CAD /* Error.swift in Sources */, + EE1A9ED7298520D600E4F9EF /* MovieListModel.swift in Sources */, + EE1A9EC529851E5700E4F9EF /* Layout.swift in Sources */, + EE1A9EE6298522EB00E4F9EF /* MovieDetailViewController.swift in Sources */, + EE1A9ECE298520B400E4F9EF /* PageSection.swift in Sources */, + EE2293D8298666EB0077B40D /* FavoriteListViewModel.swift in Sources */, + EE2293D6298666EB0077B40D /* FavoriteListBuilder.swift in Sources */, + EE2293D5298666EB0077B40D /* FavoriteListViewController.swift in Sources */, + EE1A9ED6298520D600E4F9EF /* MovieListBuilder.swift in Sources */, + EEB43ADC29850E4000DF5CAD /* AppDelegate.swift in Sources */, + EEB43ADE29850E4000DF5CAD /* SceneDelegate.swift in Sources */, + EE1A9EB729851B3800E4F9EF /* ResultList.swift in Sources */, + EE1A9EB929851B4E00E4F9EF /* Movie.swift in Sources */, + EE2293C929859F100077B40D /* StorageHelper.swift in Sources */, + EE1A9EB029851A6200E4F9EF /* MovieServiceEndPoint.swift in Sources */, + EE1A9ED8298520D600E4F9EF /* MovieListViewModel.swift in Sources */, + EE1A9ECC2985206000E4F9EF /* BaseProtocols.swift in Sources */, + EE2293C32985859F0077B40D /* LocalizeHelper.swift in Sources */, + EE1A9EE7298522EB00E4F9EF /* MovieDetailBuilder.swift in Sources */, + EE1A9EC329851E3000E4F9EF /* Constant.swift in Sources */, + EE1A9EC729851E8300E4F9EF /* Image.swift in Sources */, + EE1A9EBB29851B6900E4F9EF /* Review.swift in Sources */, + EE1A9EE8298522EB00E4F9EF /* MovieDetailModel.swift in Sources */, + EE2293C7298595F20077B40D /* Data.swift in Sources */, + EE1A9EDC2985212300E4F9EF /* MovieBannerView.swift in Sources */, + EE1A9EE9298522EB00E4F9EF /* MovieDetailViewModel.swift in Sources */, + EE2293D4298666EB0077B40D /* FavoriteListRouter.swift in Sources */, + EE1A9EE5298522EB00E4F9EF /* MovieDetailRouter.swift in Sources */, + EE1A9ED4298520D600E4F9EF /* MovieListRouter.swift in Sources */, + EE1A9EEB298523C100E4F9EF /* ReviewView.swift in Sources */, + EE2293E02986DBC00077B40D /* UIImage.swift in Sources */, + EE2293D7298666EB0077B40D /* FavoriteListModel.swift in Sources */, + EE1A9ECA29851F9800E4F9EF /* ImageLoader.swift in Sources */, + EE1A9EB229851A8100E4F9EF /* MovieNetworkAPI.swift in Sources */, + EE1A9EAE29851A1200E4F9EF /* NetworkExecuter.swift in Sources */, + EE2293E629870F1B0077B40D /* SupplementaryHeaderView.swift in Sources */, + EE2293C5298594AA0077B40D /* LoggerHelper.swift in Sources */, + EE1A9EB429851A8D00E4F9EF /* MovieNetworkAPIManager.swift in Sources */, + EE1A9ED5298520D600E4F9EF /* MovieListViewController.swift in Sources */, + EE1A9EC129851D9100E4F9EF /* URL.swift in Sources */, + EE1A9EAC298519F600E4F9EF /* EndPointTarget.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EEB43AEA29850E4100DF5CAD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EEB43B20298511E800DF5CAD /* JSONLoader.swift in Sources */, + EE1A9EEF29852A7300E4F9EF /* ExecuterTest.swift in Sources */, + EE2293E42986E6EA0077B40D /* XCTest+Extension.swift in Sources */, + EE1A9EF129852AD200E4F9EF /* MockExecuter.swift in Sources */, + EE2293E22986E1EE0077B40D /* ViewModelTest.swift in Sources */, + EEB43AF329850E4100DF5CAD /* ViewControllerTest.swift in Sources */, + EE2293CB2985AA330077B40D /* StorageTest.swift in Sources */, + EE1A9EBD29851CAB00E4F9EF /* MockService.swift in Sources */, + EE1A9EED298529A300E4F9EF /* LoadableJSONTest.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EEB43AF429850E4100DF5CAD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EEB43AFF29850E4100DF5CAD /* DarsayTechTestUITestsLaunchTests.swift in Sources */, + EEB43AFD29850E4100DF5CAD /* DarsayTechTestUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + EEB43AF029850E4100DF5CAD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EEB43AD729850E4000DF5CAD /* DarsayTechTest */; + targetProxy = EEB43AEF29850E4100DF5CAD /* PBXContainerItemProxy */; + }; + EEB43AFA29850E4100DF5CAD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EEB43AD729850E4000DF5CAD /* DarsayTechTest */; + targetProxy = EEB43AF929850E4100DF5CAD /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + EEB43AE629850E4100DF5CAD /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + EEB43AE729850E4100DF5CAD /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + EEB43B0029850E4100DF5CAD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + 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; + }; + EEB43B0129850E4100DF5CAD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + EEB43B0329850E4100DF5CAD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DarsayTechTest/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Test.DarsayTechTest; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + EEB43B0429850E4100DF5CAD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DarsayTechTest/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Test.DarsayTechTest; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + EEB43B0629850E4100DF5CAD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Test.DarsayTechTestTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DarsayTechTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DarsayTechTest"; + }; + name = Debug; + }; + EEB43B0729850E4100DF5CAD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Test.DarsayTechTestTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/DarsayTechTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/DarsayTechTest"; + }; + name = Release; + }; + EEB43B0929850E4100DF5CAD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Test.DarsayTechTestUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = DarsayTechTest; + }; + name = Debug; + }; + EEB43B0A29850E4100DF5CAD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = Test.DarsayTechTestUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = DarsayTechTest; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + EEB43AD329850E4000DF5CAD /* Build configuration list for PBXProject "DarsayTechTest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EEB43B0029850E4100DF5CAD /* Debug */, + EEB43B0129850E4100DF5CAD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EEB43B0229850E4100DF5CAD /* Build configuration list for PBXNativeTarget "DarsayTechTest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EEB43B0329850E4100DF5CAD /* Debug */, + EEB43B0429850E4100DF5CAD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EEB43B0529850E4100DF5CAD /* Build configuration list for PBXNativeTarget "DarsayTechTestTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EEB43B0629850E4100DF5CAD /* Debug */, + EEB43B0729850E4100DF5CAD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EEB43B0829850E4100DF5CAD /* Build configuration list for PBXNativeTarget "DarsayTechTestUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EEB43B0929850E4100DF5CAD /* Debug */, + EEB43B0A29850E4100DF5CAD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = EEB43AD029850E4000DF5CAD /* Project object */; +} diff --git a/DarsayTechTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/DarsayTechTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/DarsayTechTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/DarsayTechTest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/DarsayTechTest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/DarsayTechTest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/DarsayTechTest.xcodeproj/project.xcworkspace/xcuserdata/Farzaneh.xcuserdatad/UserInterfaceState.xcuserstate b/DarsayTechTest.xcodeproj/project.xcworkspace/xcuserdata/Farzaneh.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 00000000..1593820a Binary files /dev/null and b/DarsayTechTest.xcodeproj/project.xcworkspace/xcuserdata/Farzaneh.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/DarsayTechTest.xcodeproj/xcshareddata/xcschemes/DarsayTechTest.xcscheme b/DarsayTechTest.xcodeproj/xcshareddata/xcschemes/DarsayTechTest.xcscheme new file mode 100644 index 00000000..f405b72f --- /dev/null +++ b/DarsayTechTest.xcodeproj/xcshareddata/xcschemes/DarsayTechTest.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DarsayTechTest.xcodeproj/xcuserdata/Farzaneh.xcuserdatad/xcschemes/xcschememanagement.plist b/DarsayTechTest.xcodeproj/xcuserdata/Farzaneh.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..f62cf825 --- /dev/null +++ b/DarsayTechTest.xcodeproj/xcuserdata/Farzaneh.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,53 @@ + + + + + SchemeUserState + + DarsayTechTest.xcscheme_^#shared#^_ + + orderHint + 1 + + SnapKitPlayground (Playground) 1.xcscheme + + isShown + + orderHint + 2 + + SnapKitPlayground (Playground) 2.xcscheme + + isShown + + orderHint + 3 + + SnapKitPlayground (Playground).xcscheme + + isShown + + orderHint + 0 + + + SuppressBuildableAutocreation + + EEB43AD729850E4000DF5CAD + + primary + + + EEB43AED29850E4100DF5CAD + + primary + + + EEB43AF729850E4100DF5CAD + + primary + + + + + diff --git a/DarsayTechTest/AppDelegate.swift b/DarsayTechTest/AppDelegate.swift new file mode 100644 index 00000000..3f6f2d10 --- /dev/null +++ b/DarsayTechTest/AppDelegate.swift @@ -0,0 +1,31 @@ +// +// AppDelegate.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } +} diff --git a/DarsayTechTest/Constant/Constant.swift b/DarsayTechTest/Constant/Constant.swift new file mode 100644 index 00000000..5fa49692 --- /dev/null +++ b/DarsayTechTest/Constant/Constant.swift @@ -0,0 +1,12 @@ +// +// Constant.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +enum Constant { + static let imageBaseURL = "https://image.tmdb.org/t/p/" +} diff --git a/DarsayTechTest/Constant/Layout.swift b/DarsayTechTest/Constant/Layout.swift new file mode 100644 index 00000000..81da9ee2 --- /dev/null +++ b/DarsayTechTest/Constant/Layout.swift @@ -0,0 +1,10 @@ +// +// Layout.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +enum Layout {} diff --git a/DarsayTechTest/Extension/Data.swift b/DarsayTechTest/Extension/Data.swift new file mode 100644 index 00000000..3ed241f5 --- /dev/null +++ b/DarsayTechTest/Extension/Data.swift @@ -0,0 +1,18 @@ +// +// Data.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +extension Data { + var prettyPrintedJSONString: String? { + guard let object = try? JSONSerialization.jsonObject(with: self, options: []), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]), + let prettyPrintedString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) as String? else { return nil } + + return prettyPrintedString + } +} diff --git a/DarsayTechTest/Extension/Publisher.swift b/DarsayTechTest/Extension/Publisher.swift new file mode 100644 index 00000000..ae56be04 --- /dev/null +++ b/DarsayTechTest/Extension/Publisher.swift @@ -0,0 +1,26 @@ +// +// Publisher.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation +import Combine + +extension Publisher { + + // MARK: - Combine + + func sinkToResult(_ result: @escaping (Result) -> Void) -> AnyCancellable { + return sink(receiveCompletion: { completion in + switch completion { + case let .failure(error): + result(.failure(error)) + default: break + } + }, receiveValue: { value in + result(.success(value)) + }) + } +} diff --git a/DarsayTechTest/Extension/UIColor.swift b/DarsayTechTest/Extension/UIColor.swift new file mode 100644 index 00000000..83cdae2d --- /dev/null +++ b/DarsayTechTest/Extension/UIColor.swift @@ -0,0 +1,19 @@ +// +// UIColor.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/9/1401 AP. +// + +import Foundation +import UIKit + +extension UIColor { + static let background = UIColor(named: "background") + static let onBackground = UIColor(named: "onBackground") + static let onBackgroundPrimary = UIColor(named: "onBackgroundPrimary") + static let positiveGreen = UIColor(named: "positiveGreen") + static let negative = UIColor(named: "negative") + static let highlightPositive = UIColor(named: "highlightPositive") + static let onHighlightPositive = UIColor(named: "onHighlightPositive") +} diff --git a/DarsayTechTest/Extension/UIImage.swift b/DarsayTechTest/Extension/UIImage.swift new file mode 100644 index 00000000..ed8ed49d --- /dev/null +++ b/DarsayTechTest/Extension/UIImage.swift @@ -0,0 +1,25 @@ +// +// UIImage.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/9/1401 AP. +// + +import Foundation +import UIKit + +extension UIImage { + + enum SFSymbol: String { + case favorite = "heart" + case filledFavorite = "heart.fill" + case favoriteList = "heart.text.square.fill" + } + + convenience init?(symbolicName: SFSymbol, pointSize: CGFloat) { + + let symbolConfiguration = UIImage.SymbolConfiguration(pointSize: pointSize) + + self.init(systemName: symbolicName.rawValue, withConfiguration: symbolConfiguration) + } +} diff --git a/DarsayTechTest/Extension/URL.swift b/DarsayTechTest/Extension/URL.swift new file mode 100644 index 00000000..39313ab5 --- /dev/null +++ b/DarsayTechTest/Extension/URL.swift @@ -0,0 +1,23 @@ +// +// URL.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +extension URL { + + static func getFullPath(sizeType: Image.Types = .backDrop(.w300), nestedURLString: String) throws -> Self { + + let urlString = [ + Constant.imageBaseURL, + sizeType.sizeString, + nestedURLString + ].joined(separator: "") + + guard let url = URL(string: urlString) else { throw AppError(reason: LocalizeHelper.shared.lookup(.urlCreationFailed)) } + return url + } +} diff --git a/DarsayTechTest/Helper/ImageLoader.swift b/DarsayTechTest/Helper/ImageLoader.swift new file mode 100644 index 00000000..995d44d4 --- /dev/null +++ b/DarsayTechTest/Helper/ImageLoader.swift @@ -0,0 +1,31 @@ +// +// ImageLoader.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Combine +import Foundation +import UIKit + +final class ImageLoader { + static let shared = ImageLoader() + + private lazy var backgroundQueue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 5 + return queue + }() + + func loadImage(from url: URL?) -> AnyPublisher { + guard let url else { return Just(nil).eraseToAnyPublisher() } + + return URLSession.shared.dataTaskPublisher(for: url) + .map { (data, _) -> UIImage? in return UIImage(data: data) } + .catch { _ in return Just(nil) } + .subscribe(on: backgroundQueue) + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } +} diff --git a/DarsayTechTest/Helper/LocalizeHelper.swift b/DarsayTechTest/Helper/LocalizeHelper.swift new file mode 100644 index 00000000..4e14f0a7 --- /dev/null +++ b/DarsayTechTest/Helper/LocalizeHelper.swift @@ -0,0 +1,30 @@ +// +// LocalizeHelper.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +final class LocalizeHelper { + + static let shared = LocalizeHelper() + + enum Localizedkey: String { + + case ok + case errorTitle + case unknownError + case urlCreationFailed + case favoriteListTitle + case popularListTitle + case topRatedListTitle + } + + func lookup(_ key: Localizedkey, _ args: CVarArg...) -> String { + + let format = NSLocalizedString(key.rawValue, comment: "") + return String(format: format, locale: Locale.current, arguments: args) + } +} diff --git a/DarsayTechTest/Helper/LoggerHelper.swift b/DarsayTechTest/Helper/LoggerHelper.swift new file mode 100644 index 00000000..a01b3bc7 --- /dev/null +++ b/DarsayTechTest/Helper/LoggerHelper.swift @@ -0,0 +1,58 @@ +// +// LoggerHelper.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import OSLog +import Foundation + +protocol LogNetworkProtocol { + func log(_ response: HTTPURLResponse?, data: Data?, error: Error?, HTTPMethod: String?) +} + +final class LoggerHelper: LogNetworkProtocol { + + static let shared = LoggerHelper() + + let ENABLELOG = true + + func log(_ response: HTTPURLResponse? = nil, data: Data? = nil, error: Error? = nil, HTTPMethod: String? = nil) { + guard ENABLELOG else { return } + + print("\n🔵 ========== Start logResponse ========== 🔵") + defer { + print("🟦 ========== End logResponse ========== 🟦\n") + } + guard let response = response else { + print("==", "❌ NULL Response ERROR: ❌") + return + } + if let url = response.url?.absoluteString { + print("==", "Request URL: `\(url)`") + print("==", "Response CallBack Status Code: `\(response.statusCode)`") + } else { + print("==", "❌ LOG ERROR: ❌") + print("==", "Empty URL") + } + if let method = HTTPMethod { + print("==", "Request HTTPMethod: `\(method)`") + } + if let error = error { + print("==", "❌ GOT URL REQUEST ERROR: ❌") + print(error) + } + guard let data = data else { + print("==", "❌ Empty Response ERROR: ❌") + return + } + print("==", "✅ Response CallBack Data: ✅") + if let json = data.prettyPrintedJSONString { + print(json) + } else { + let responseDataString: String = String(data: data, encoding: .utf8) ?? "BAD ENCODING" + print(responseDataString) + } + } +} diff --git a/DarsayTechTest/Helper/StorageHelper.swift b/DarsayTechTest/Helper/StorageHelper.swift new file mode 100644 index 00000000..20e554ad --- /dev/null +++ b/DarsayTechTest/Helper/StorageHelper.swift @@ -0,0 +1,73 @@ +// +// StorageHelper.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +protocol StorageProtocol { + + associatedtype StoredObject: Codable + + func setObject(object: StoredObject) + func getObject() -> StoredObject? + func remove() +} + +final class FavoriteStorage: StorageProtocol { + typealias StoredObject = [Movie] + + private static let favoriteListKey = "favoriteListKey" + + private init() {} + + static let shared = FavoriteStorage() + + var currentList: [Movie] { + get { + return getObject() ?? [] + } + + set { + return setObject(object: newValue) + } + } + + func append(movie: Movie) { + + guard !currentList.contains(where: { $0.id == movie.id }) else { return } + + var tempList = currentList + tempList.append(movie) + currentList = tempList + } + + func remove(movie: Movie) { + var tempList = currentList + + if let index = tempList.firstIndex(of: movie) { + tempList.remove(at: index) + currentList = tempList + } + } + + func setObject(object: StoredObject) { + let data = try? JSONEncoder().encode(object) + UserDefaults.standard.set(data, forKey: Self.favoriteListKey) + } + + func getObject() -> StoredObject? { + guard let data = UserDefaults.standard.object(forKey: Self.favoriteListKey) as? Data else { + return nil + } + + let value = try? JSONDecoder().decode(StoredObject.self, from: data) + return value + } + + func remove() { + UserDefaults.standard.removeObject(forKey: Self.favoriteListKey) + } +} diff --git a/DarsayTechTest/Info.plist b/DarsayTechTest/Info.plist new file mode 100644 index 00000000..0eb786dc --- /dev/null +++ b/DarsayTechTest/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/DarsayTechTest/Models/Error.swift b/DarsayTechTest/Models/Error.swift new file mode 100644 index 00000000..84ec6c7a --- /dev/null +++ b/DarsayTechTest/Models/Error.swift @@ -0,0 +1,28 @@ +// +// Error.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +struct AppError: Error, LocalizedError { + + let reason: String? + + var errorDescription: String? { + reason ?? LocalizeHelper.shared.lookup(.unknownError) + } +} + +enum NetworkError: Error { + case badURL + case invalidJSON + case serverError + case noResponse + case unauthorized + case generalError + case decodeFailed + case notFound +} diff --git a/DarsayTechTest/Models/Networking Models/Image.swift b/DarsayTechTest/Models/Networking Models/Image.swift new file mode 100644 index 00000000..6a956d7b --- /dev/null +++ b/DarsayTechTest/Models/Networking Models/Image.swift @@ -0,0 +1,58 @@ +// +// Image.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +enum Image { + + enum Types { + case backDrop(BDSize) + case logo(LogoSizes) + case poster(PosterSizes) + case profile(LogoSizes) + + var sizeString: String { + switch self { + case .backDrop(let bDSize): + return bDSize.rawValue + case .logo(let logoSizes): + return logoSizes.rawValue + case .poster(let posterSizes): + return posterSizes.rawValue + case .profile(let logoSizes): + return logoSizes.rawValue + } + } + } + + enum PosterSizes: String, Codable { + case w92 + case w154 + case w185 + case w342 + case w500 + case w780 + case original + } + + enum BDSize: String, Codable { + case w300 + case w780 + case w1280 + case original + } + + enum LogoSizes: String, Codable { + case w45 + case w92 + case w154 + case w185 + case w300 + case w500 + case original + } +} diff --git a/DarsayTechTest/Models/Networking Models/Movie.swift b/DarsayTechTest/Models/Networking Models/Movie.swift new file mode 100644 index 00000000..87a04780 --- /dev/null +++ b/DarsayTechTest/Models/Networking Models/Movie.swift @@ -0,0 +1,76 @@ +// +// Movie.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +class Movie: Codable, Equatable { + static func == (lhs: Movie, rhs: Movie) -> Bool { + lhs.id == rhs.id + } + + let adult: Bool + let backdropPath: String + let genreIDS: [Int]? + let id: Int + let originalLanguage: String + let originalTitle: String + let overview: String + let popularity: Double + let posterPath: String? + let releaseDate: String + let title: String + let video: Bool + let voteAverage: Double + let voteCount: Int + let imdbID: String? + let tagline: String? + var isFaved: Bool? + + enum CodingKeys: String, CodingKey { + case adult + case overview + case popularity + case id + case title + case video + case backdropPath = "backdrop_path" + case genreIDS = "genre_ids" + case originalLanguage = "original_language" + case originalTitle = "original_title" + case posterPath = "poster_path" + case releaseDate = "release_date" + case voteAverage = "vote_average" + case voteCount = "vote_count" + case imdbID = "imdb_id" + case tagline + case isFaved + } + + init(adult: Bool, backdropPath: String, genreIDS: [Int]?, id: Int, + originalLanguage: String, originalTitle: String, + overview: String, popularity: Double, posterPath: String?, + releaseDate: String, title: String, video: Bool, voteAverage: Double, + voteCount: Int, imdbID: String?, tagline: String?, isFaved: Bool? = nil) { + self.adult = adult + self.backdropPath = backdropPath + self.genreIDS = genreIDS + self.id = id + self.originalLanguage = originalLanguage + self.originalTitle = originalTitle + self.overview = overview + self.popularity = popularity + self.posterPath = posterPath + self.releaseDate = releaseDate + self.title = title + self.video = video + self.voteAverage = voteAverage + self.voteCount = voteCount + self.imdbID = imdbID + self.tagline = tagline + self.isFaved = isFaved + } +} diff --git a/DarsayTechTest/Models/Networking Models/ResultList.swift b/DarsayTechTest/Models/Networking Models/ResultList.swift new file mode 100644 index 00000000..8f5f3913 --- /dev/null +++ b/DarsayTechTest/Models/Networking Models/ResultList.swift @@ -0,0 +1,22 @@ +// +// ResultList.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +struct ResultList: Codable { + let page: Int + let totalPages: Int + let totalResults: Int + let results: [T] + + enum CodingKeys: String, CodingKey { + case page + case results + case totalPages = "total_pages" + case totalResults = "total_results" + } +} diff --git a/DarsayTechTest/Models/Networking Models/Review.swift b/DarsayTechTest/Models/Networking Models/Review.swift new file mode 100644 index 00000000..ffa79a94 --- /dev/null +++ b/DarsayTechTest/Models/Networking Models/Review.swift @@ -0,0 +1,37 @@ +// +// Review.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +struct Review: Codable { + + let author: String + let authorDetail: AuthorDetail + let url: String + let id: String + let content: String + + enum CodingKeys: String, CodingKey { + case author + case authorDetail = "author_details" + case url + case id + case content + } +} + +struct AuthorDetail: Codable { + let name: String + let username: String + let avatarPath: String + + enum CodingKeys: String, CodingKey { + case name + case username + case avatarPath = "avatar_path" + } +} diff --git a/DarsayTechTest/Models/PageSection.swift b/DarsayTechTest/Models/PageSection.swift new file mode 100644 index 00000000..e71f7734 --- /dev/null +++ b/DarsayTechTest/Models/PageSection.swift @@ -0,0 +1,17 @@ +// +// PageSection.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +struct PageSection: Identifiable, Hashable { + typealias ID = String + let id: ID + + var cellReuseIdentifier: String { + "cell-\(id)" + } +} diff --git a/DarsayTechTest/Networking/Base/EndPointTarget.swift b/DarsayTechTest/Networking/Base/EndPointTarget.swift new file mode 100644 index 00000000..c8372fea --- /dev/null +++ b/DarsayTechTest/Networking/Base/EndPointTarget.swift @@ -0,0 +1,48 @@ +// +// EndPointTarget.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +protocol EndPointTarget { + var baseURL: String { get } + var path: String { get } + var parameter: [URLQueryItem] { get } + var headers: [String : String] { get } + var method: HTTPMethod { get } + func getURL() -> URL? +} + +extension EndPointTarget { + var baseURL: String { + return "api.themoviedb.org" + } + + var headers: [String : String] { + let accessToken = "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI4YTdhOWFhMjYyODZhOGMyZDE0OTBhNTIyNzljNzQwOSIsInN1YiI6IjYzY2ZiOGZjOGRiYzMzMDA5ZDdhNjBlZiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.g3Fkog48ELAPsq80yL65wFfiNw-ZWcsGY74_NdzWz0o" + + return [ + "Authorization": "Bearer \(accessToken)", + "Content-Type": "application/json;charset=utf-8" + ] + } + + func getURL() -> URL? { + var component = URLComponents() + component.scheme = "https" + component.host = baseURL + component.path = path + component.queryItems = parameter + return component.url + } +} + +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} diff --git a/DarsayTechTest/Networking/Base/NetworkExecuter.swift b/DarsayTechTest/Networking/Base/NetworkExecuter.swift new file mode 100644 index 00000000..3374bc87 --- /dev/null +++ b/DarsayTechTest/Networking/Base/NetworkExecuter.swift @@ -0,0 +1,56 @@ +// +// NetworkExecuter.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation +import Combine +import UIKit + +protocol HTTPRequestProtocol { + func performRequest(endpoint: EndPointTarget, responseModel: T.Type, cachedResponseOnError: Bool) -> AnyPublisher +} + +class HTTPRequestExecuter: HTTPRequestProtocol { + + var urlSession: URLSession + var timeoutInterval: Double + + init(urlSession: URLSession = URLSession.shared, timeoutInterval: Double) { + self.urlSession = urlSession + self.timeoutInterval = timeoutInterval + } + + func performRequest(endpoint: EndPointTarget, responseModel: T.Type, cachedResponseOnError: Bool) -> AnyPublisher { + + guard let url = endpoint.getURL() else { return Fail(error: NetworkError.badURL).eraseToAnyPublisher() } + + var request = URLRequest(url: url, timeoutInterval: timeoutInterval) + request.httpMethod = endpoint.method.rawValue + request.allHTTPHeaderFields = endpoint.headers + + return urlSession.dataTaskPublisher(for: request).tryMap { [weak self] data, response in + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.noResponse + } + guard 200..<400 ~= httpResponse.statusCode else { + + guard let urlCache = self?.urlSession.configuration.urlCache, let cachedResponse = urlCache.cachedResponse(for: request) else { + throw NetworkError.serverError + } + LoggerHelper.shared.log(data: data) + return cachedResponse.data + } + LoggerHelper.shared.log(httpResponse, data: data) + return data + } + .decode(type: T.self, decoder: JSONDecoder()) + .mapError { error in + LoggerHelper.shared.log(error: error) + return AppError(reason: error.localizedDescription) + } + .eraseToAnyPublisher() + } +} diff --git a/DarsayTechTest/Networking/Manager/MovieNetworkAPI.swift b/DarsayTechTest/Networking/Manager/MovieNetworkAPI.swift new file mode 100644 index 00000000..d39e1ee0 --- /dev/null +++ b/DarsayTechTest/Networking/Manager/MovieNetworkAPI.swift @@ -0,0 +1,16 @@ +// +// MovieNetworkAPI.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation +import Combine + +protocol MovieNetworkAPI { + func getPopularMovies() -> AnyPublisher, Error> + func getTopRatedMovies() -> AnyPublisher, Error> + func getMovieDetail(movieID: String) -> AnyPublisher + func getMovieReviews(movieID: String) -> AnyPublisher, Error> +} diff --git a/DarsayTechTest/Networking/Manager/MovieNetworkAPIManager.swift b/DarsayTechTest/Networking/Manager/MovieNetworkAPIManager.swift new file mode 100644 index 00000000..f94fb1f2 --- /dev/null +++ b/DarsayTechTest/Networking/Manager/MovieNetworkAPIManager.swift @@ -0,0 +1,36 @@ +// +// MovieNetworkAPIManager.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation +import Combine + +class MovieNetworkAPIManager: MovieNetworkAPI { + + var executer: HTTPRequestProtocol + + init(executer: HTTPRequestProtocol) { + self.executer = executer + } + + static let shared = MovieNetworkAPIManager(executer: HTTPRequestExecuter(timeoutInterval: 40)) + + func getPopularMovies() -> AnyPublisher, Error> { + return executer.performRequest(endpoint: MovieServiceEndPoint.getMostPopular, responseModel: ResultList.self, cachedResponseOnError: true) + } + + func getTopRatedMovies() -> AnyPublisher, Error> { + return executer.performRequest(endpoint: MovieServiceEndPoint.getTopRated, responseModel: ResultList.self, cachedResponseOnError: true) + } + + func getMovieDetail(movieID: String) -> AnyPublisher { + return executer.performRequest(endpoint: MovieServiceEndPoint.getDetail(movieID), responseModel: Movie.self, cachedResponseOnError: true) + } + + func getMovieReviews(movieID: String) -> AnyPublisher, Error> { + return executer.performRequest(endpoint: MovieServiceEndPoint.getReviews(movieID), responseModel: ResultList.self, cachedResponseOnError: true) + } +} diff --git a/DarsayTechTest/Networking/Service/MovieServiceEndPoint.swift b/DarsayTechTest/Networking/Service/MovieServiceEndPoint.swift new file mode 100644 index 00000000..1a3ff6a7 --- /dev/null +++ b/DarsayTechTest/Networking/Service/MovieServiceEndPoint.swift @@ -0,0 +1,39 @@ +// +// MovieServiceEndPoint.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation + +enum MovieServiceEndPoint { + case getTopRated + case getMostPopular + case getDetail(String) + case getReviews(String) +} + +extension MovieServiceEndPoint: EndPointTarget { + var path: String { + + switch self { + case .getTopRated: + return "/3/movie/top_rated" + case .getMostPopular: + return "/3/movie/popular" + case .getDetail(let id): + return "/3/movie/\(id)" + case .getReviews(let id): + return "/3/movie/\(id)/reviews" + } + } + + var parameter: [URLQueryItem] { + [] + } + + var method: HTTPMethod { + .get + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/background.colorset/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/background.colorset/Contents.json new file mode 100644 index 00000000..879f6645 --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/background.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.157", + "green" : "0.125", + "red" : "0.106" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/highlightPositive.colorset/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/highlightPositive.colorset/Contents.json new file mode 100644 index 00000000..1d2ee44a --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/highlightPositive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "232", + "green" : "237", + "red" : "201" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "65", + "green" : "70", + "red" : "21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/negative.colorset/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/negative.colorset/Contents.json new file mode 100644 index 00000000..285e9fb5 --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/negative.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "59", + "green" : "13", + "red" : "255" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "139", + "green" : "113", + "red" : "235" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/onBackground.colorset/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/onBackground.colorset/Contents.json new file mode 100644 index 00000000..c6820479 --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/onBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "156", + "green" : "142", + "red" : "113" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "0.624" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/onBackgroundPrimary.colorset/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/onBackgroundPrimary.colorset/Contents.json new file mode 100644 index 00000000..4727c4b2 --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/onBackgroundPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "53", + "green" : "48", + "red" : "37" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/onHighlightPositive.colorset/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/onHighlightPositive.colorset/Contents.json new file mode 100644 index 00000000..4727c4b2 --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/onHighlightPositive.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "53", + "green" : "48", + "red" : "37" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Assets.xcassets/positiveGreen.colorset/Contents.json b/DarsayTechTest/Resources/Assets.xcassets/positiveGreen.colorset/Contents.json new file mode 100644 index 00000000..30fc121e --- /dev/null +++ b/DarsayTechTest/Resources/Assets.xcassets/positiveGreen.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "72", + "green" : "171", + "red" : "0" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "146", + "green" : "189", + "red" : "0" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DarsayTechTest/Resources/Base.lproj/LaunchScreen.storyboard b/DarsayTechTest/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/DarsayTechTest/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DarsayTechTest/Resources/Localizable.stringsdict b/DarsayTechTest/Resources/Localizable.stringsdict new file mode 100644 index 00000000..737c43ec --- /dev/null +++ b/DarsayTechTest/Resources/Localizable.stringsdict @@ -0,0 +1,83 @@ + + + + + unknownError + + NSStringLocalizedFormatKey + unknown error + + urlCreationFailed + + NSStringLocalizedFormatKey + URL creation failed" + Variable + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + other + + + + favoriteListTitle + + NSStringLocalizedFormatKey + Favorite List + Variable + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + other + + + + ok + + NSStringLocalizedFormatKey + OK + Variable + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + other + + + + errorTitle + + NSStringLocalizedFormatKey + Error! + Variable + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + other + + + + popularListTitle + + NSStringLocalizedFormatKey + Popular Movies + Variable + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + other + + + + topRatedListTitle + + NSStringLocalizedFormatKey + Top Rated Movies + Variable + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + other + + + + + diff --git a/DarsayTechTest/SceneDelegate.swift b/DarsayTechTest/SceneDelegate.swift new file mode 100644 index 00000000..bec6ac5e --- /dev/null +++ b/DarsayTechTest/SceneDelegate.swift @@ -0,0 +1,57 @@ +// +// SceneDelegate.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let windowScene = (scene as? UIWindowScene) else { return } + + let movieList = MovieListBuilder.build(with: .init(movieNetworkAPIManager: MovieNetworkAPIManager.shared)) + + let movieListNavigation = UINavigationController(rootViewController: movieList) + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = movieListNavigation + self.window = window + window.makeKeyAndVisible() + } + + 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/DarsayTechTest/Scenes/Base/BaseProtocols.swift b/DarsayTechTest/Scenes/Base/BaseProtocols.swift new file mode 100644 index 00000000..3032103a --- /dev/null +++ b/DarsayTechTest/Scenes/Base/BaseProtocols.swift @@ -0,0 +1,69 @@ +// +// BaseProtocols.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation +import Combine +import UIKit + +protocol BaseSceneViewController: UIViewController { + func bind() + func prepareUI() +} + +protocol BaseSceneBuilder { + + associatedtype Config + associatedtype SceneView: BaseSceneViewController + + static func build(with configuration: Config) -> SceneView +} + +extension BaseSceneBuilder where Config == Void { + + static func build() -> SceneView { + return Self.build(with: ()) + } +} + +protocol StateProtocol { + func updated(_ handler: (inout Self) -> Void) -> Self +} + +extension StateProtocol { + func updated(_ handler: (inout Self) -> Void) -> Self { + var result = self + handler(&result) + return result + } + + mutating func update(_ handler: (inout Self) -> Void) { + var result = self + handler(&result) + self = result + } + + mutating func update(_ keyPath: WritableKeyPath, to value: Value) { + self[keyPath: keyPath] = value + } +} + +protocol ViewModel: AnyObject { + associatedtype State: StateProtocol + associatedtype Action + var state: State { get } + var statePublisher: AnyPublisher { get } + func handle(action: Action) +} + +protocol SubjectedViewModel: ViewModel { + var stateSubject: CurrentValueSubject { get } +} + +extension SubjectedViewModel { + var state: State { stateSubject.value } + var statePublisher: AnyPublisher { stateSubject.eraseToAnyPublisher() } +} diff --git a/DarsayTechTest/Scenes/FavoriteList/FavoriteListBuilder.swift b/DarsayTechTest/Scenes/FavoriteList/FavoriteListBuilder.swift new file mode 100644 index 00000000..20a131b0 --- /dev/null +++ b/DarsayTechTest/Scenes/FavoriteList/FavoriteListBuilder.swift @@ -0,0 +1,30 @@ +// +// FavoriteListBuilder.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/9/1401 AP. + +import UIKit + +enum FavoriteListBuilder: BaseSceneBuilder { + + typealias Config = FavoriteList.Configuration + + typealias SceneView = FavoriteListViewController + + static func build(with configuration: Config) -> SceneView { + + let viewModel = viewModelBuilder(configuration: configuration) + let router = FavoriteListRouter() + let viewController = SceneView(viewModel: viewModel, router: router) + + router.viewController = viewController + + return viewController + } + + private static func viewModelBuilder(configuration: Config) -> some ViewModel { + FavoriteListViewModel(configuration: configuration) + } + +} diff --git a/DarsayTechTest/Scenes/FavoriteList/FavoriteListModel.swift b/DarsayTechTest/Scenes/FavoriteList/FavoriteListModel.swift new file mode 100644 index 00000000..8fd55b55 --- /dev/null +++ b/DarsayTechTest/Scenes/FavoriteList/FavoriteListModel.swift @@ -0,0 +1,22 @@ +// +// FavoriteListModel.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/9/1401 AP. + +import Foundation + +enum FavoriteList { + + struct Configuration { + + } + + struct State: StateProtocol { + var favoriteList: [Movie]? + } + + enum Action { + case fetchFavoriteMovies + } +} diff --git a/DarsayTechTest/Scenes/FavoriteList/FavoriteListRouter.swift b/DarsayTechTest/Scenes/FavoriteList/FavoriteListRouter.swift new file mode 100644 index 00000000..93a74a73 --- /dev/null +++ b/DarsayTechTest/Scenes/FavoriteList/FavoriteListRouter.swift @@ -0,0 +1,22 @@ +// +// FavoriteListRouter.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/9/1401 AP. + +import UIKit + +protocol FavoriteListRouterProtocol: AnyObject { + func routeToDetail(movieID: Int) +} + +class FavoriteListRouter: FavoriteListRouterProtocol { + + weak var viewController: FavoriteListViewController? + + func routeToDetail(movieID: Int) { + let destination = MovieDetailBuilder.build(with: .init(movieID: movieID, movieNetworkAPIManager: MovieNetworkAPIManager.shared)) + + self.viewController?.navigationController?.pushViewController(destination, animated: true) + } +} diff --git a/DarsayTechTest/Scenes/FavoriteList/FavoriteListViewController.swift b/DarsayTechTest/Scenes/FavoriteList/FavoriteListViewController.swift new file mode 100644 index 00000000..b3a28fb6 --- /dev/null +++ b/DarsayTechTest/Scenes/FavoriteList/FavoriteListViewController.swift @@ -0,0 +1,206 @@ +// +// FavoriteListViewController.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/9/1401 AP. + +import Combine +import Foundation +import UIKit + +fileprivate extension Layout { + static let contentScrollViewContentInsetBottom: CGFloat = 100 + static let favoriteGroupHeight: CGFloat = 150 +} + +fileprivate extension PageSection { + static let favoriteSection = Self.init(id: "section-favorite") +} + +class FavoriteListViewController: UIViewController, BaseSceneViewController { + + // MARK: - Variables + + private var router: FavoriteListRouterProtocol? + private var viewModel: any ViewModel + var cancellables = Set() + + var sections: [PageSection] { + [ .favoriteSection ] + } + // MARK: - Initialization + + init(viewModel: any ViewModel, router: FavoriteListRouterProtocol?) { + self.viewModel = viewModel + self.router = router + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - UI Components + + private lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout()) + + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + collectionView.semanticContentAttribute = .forceLeftToRight + collectionView.backgroundColor = .background + collectionView.contentInset.bottom = Layout.contentScrollViewContentInsetBottom + collectionView.delegate = self + collectionView.dataSource = self + collectionView.translatesAutoresizingMaskIntoConstraints = false + + let cellReuseIdentifiers = [ + PageSection.favoriteSection.cellReuseIdentifier ] + + for reuseIdentifier in cellReuseIdentifiers { + collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + } + + return collectionView + }() + + // MARK: - Prepare Layout + + func collectionViewLayout() -> UICollectionViewLayout { + UICollectionViewCompositionalLayout.init { [unowned self] sectionIndex, _ in + + let section = self.sections[sectionIndex] + switch section { + case .favoriteSection: + return getFavoriteCollectionLayoutSection() + + default: + fatalError("Invalid section: \(section)") + } + } + } + + private func getFavoriteCollectionLayoutSection() -> NSCollectionLayoutSection { + + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) + + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.contentInsets = .init(top: 8, leading: 16, bottom: 8, trailing: 16) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(Layout.favoriteGroupHeight)) + + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 1) + let section = NSCollectionLayoutSection(group: group) + + return section + } + + // MARK: - Bind + + func bind() { + viewModel.statePublisher.compactMap(\.favoriteList).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] _ in + guard let self else { return } + + self.releadList() + + }).store(in: &cancellables) + } + + // MARK: - Prepare UI + + func prepareUI() { + + view.backgroundColor = .background + title = LocalizeHelper.shared.lookup(.favoriteListTitle) + // add subviews + view.addSubview(collectionView) + setConstraints() + } + + // MARK: - Constraints + + func setConstraints() { + + let constraints = [ + collectionView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + collectionView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), + collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ] + + NSLayoutConstraint.activate(constraints) + } + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + + prepareUI() + + bind() + + viewModel.handle(action: .fetchFavoriteMovies) + } + + // MARK: - Actions + + func releadList(completion: (() -> Void)? = nil) { + collectionView.reloadData() + completion?() + } +} + +// MARK: - UICollectionViewDelegate + +extension FavoriteListViewController: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + let section = self.sections[indexPath.section] + switch section { + case .favoriteSection: + guard let movie = self.viewModel.state.favoriteList?[indexPath.row] else { return } + self.router?.routeToDetail(movieID: movie.id) + default: + return + } + } +} + +// MARK: - UICollectionViewDataSource + +extension FavoriteListViewController: UICollectionViewDataSource { + + func numberOfSections(in collectionView: UICollectionView) -> Int { + self.sections.count + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + + let section = self.sections[section] + + switch section { + case .favoriteSection: + return self.viewModel.state.favoriteList?.count ?? 0 + default: + return 0 + } + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + + let section = self.sections[indexPath.section] + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: self.sections[indexPath.section].cellReuseIdentifier, for: indexPath) + + switch section { + case .favoriteSection: + guard let movieList = self.viewModel.state.favoriteList else { return UICollectionViewCell() } + + cell.contentConfiguration = MovieBannerView.Configuration(movie: movieList[indexPath.row], hasFavoriteButton: false) + default: + return UICollectionViewCell() + } + return cell + } +} diff --git a/DarsayTechTest/Scenes/FavoriteList/FavoriteListViewModel.swift b/DarsayTechTest/Scenes/FavoriteList/FavoriteListViewModel.swift new file mode 100644 index 00000000..35441026 --- /dev/null +++ b/DarsayTechTest/Scenes/FavoriteList/FavoriteListViewModel.swift @@ -0,0 +1,35 @@ +// +// FavoriteListViewModel.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/9/1401 AP. + +import Combine +import Foundation + +class FavoriteListViewModel: SubjectedViewModel { + + typealias State = FavoriteList.State + + typealias Action = FavoriteList.Action + + let stateSubject: CurrentValueSubject + + init(configuration: FavoriteList.Configuration) { + stateSubject = .init(.init()) + } + + func handle(action: Action) { + switch action { + case .fetchFavoriteMovies: + fetchFavoriteMovies() + } + } + + private func fetchFavoriteMovies() { + + self.stateSubject.value.update({ + $0.favoriteList = FavoriteStorage.shared.currentList + }) + } +} diff --git a/DarsayTechTest/Scenes/MovieDetail/MovieDetailBuilder.swift b/DarsayTechTest/Scenes/MovieDetail/MovieDetailBuilder.swift new file mode 100644 index 00000000..bd37fead --- /dev/null +++ b/DarsayTechTest/Scenes/MovieDetail/MovieDetailBuilder.swift @@ -0,0 +1,30 @@ +// +// MovieDetailBuilder.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import UIKit + +enum MovieDetailBuilder: BaseSceneBuilder { + + typealias Config = MovieDetail.Configuration + + typealias SceneView = MovieDetailViewController + + static func build(with configuration: Config) -> SceneView { + + let viewModel = viewModelBuilder(configuration: configuration) + let router = MovieDetailRouter() + let viewController = SceneView(viewModel: viewModel, router: router) + + router.viewController = viewController + + return viewController + } + + private static func viewModelBuilder(configuration: Config) -> some ViewModel { + MovieDetailViewModel(configuration: configuration) + } + +} diff --git a/DarsayTechTest/Scenes/MovieDetail/MovieDetailModel.swift b/DarsayTechTest/Scenes/MovieDetail/MovieDetailModel.swift new file mode 100644 index 00000000..2c639156 --- /dev/null +++ b/DarsayTechTest/Scenes/MovieDetail/MovieDetailModel.swift @@ -0,0 +1,26 @@ +// +// MovieDetailModel.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import Foundation + +enum MovieDetail { + + struct Configuration { + var movieID: Int + var movieNetworkAPIManager: MovieNetworkAPI + } + + struct State: StateProtocol { + var movie: Movie? + var error: Error? + var reviewList: [Review]? + } + + enum Action { + case fetchDetail + case fetchReviews + } +} diff --git a/DarsayTechTest/Scenes/MovieDetail/MovieDetailRouter.swift b/DarsayTechTest/Scenes/MovieDetail/MovieDetailRouter.swift new file mode 100644 index 00000000..234ab4ba --- /dev/null +++ b/DarsayTechTest/Scenes/MovieDetail/MovieDetailRouter.swift @@ -0,0 +1,25 @@ +// +// MovieDetailRouter.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import UIKit + +protocol MovieDetailRouterProtocol: AnyObject { + func showErrorAlert(message: String) +} + +final class MovieDetailRouter: MovieDetailRouterProtocol { + + weak var viewController: MovieDetailViewController? + + func showErrorAlert(message: String) { + let alertController = UIAlertController(title: LocalizeHelper.shared.lookup(.errorTitle), + message: message, + preferredStyle: .alert) + + alertController.addAction(UIAlertAction(title: LocalizeHelper.shared.lookup(.ok), style: .default, handler: nil)) + self.viewController?.present(alertController, animated: true) + } +} diff --git a/DarsayTechTest/Scenes/MovieDetail/MovieDetailViewController.swift b/DarsayTechTest/Scenes/MovieDetail/MovieDetailViewController.swift new file mode 100644 index 00000000..ea2b704b --- /dev/null +++ b/DarsayTechTest/Scenes/MovieDetail/MovieDetailViewController.swift @@ -0,0 +1,307 @@ +// +// MovieDetailViewController.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import Combine +import Foundation +import UIKit + +fileprivate extension Layout { + static let bannersSectionGroupWidthInset: CGFloat = 48 + static let contentScrollViewContentInsetBottom: CGFloat = 100 + static let reviewSectionContentInsetBottom: CGFloat = 40 + static let reviewGroupHeight: CGFloat = 200 + static let bannerHeight: CGFloat = 160 +} + +fileprivate extension PageSection { + static let reviewSection = Self.init(id: "section-review") +} + +final class MovieDetailViewController: UIViewController, BaseSceneViewController { + + // MARK: - Variables + + let router: MovieDetailRouterProtocol? + let viewModel: any ViewModel + var cancellables = Set() + + var sections: [PageSection] { + [.reviewSection] + } + + // MARK: - UI Components + + lazy var imageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.textColor = .onBackgroundPrimary + label.font = UIFont.boldSystemFont(ofSize: 16.0) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + lazy var popularityLabel: UILabel = { + let label = UILabel() + label.textColor = .positiveGreen + label.font = UIFont.boldSystemFont(ofSize: 16.0) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + lazy var overviewLabel: UILabel = { + let label = UILabel() + label.textColor = .onBackground + label.font = UIFont.systemFont(ofSize: 16) + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout()) + + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + collectionView.semanticContentAttribute = .forceLeftToRight + collectionView.backgroundColor = .background + collectionView.contentInset.bottom = Layout.contentScrollViewContentInsetBottom + collectionView.dataSource = self + collectionView.translatesAutoresizingMaskIntoConstraints = false + + let cellReuseIdentifiers = [ PageSection.reviewSection.cellReuseIdentifier ] + + for reuseIdentifier in cellReuseIdentifiers { + collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + } + + return collectionView + }() + + // MARK: - Initialization + + init(viewModel: any ViewModel, router: MovieDetailRouterProtocol?) { + self.viewModel = viewModel + self.router = router + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - Life Cycle + + override func viewDidLoad() { + + prepareUI() + + bind() + + self.viewModel.handle(action: .fetchDetail) + + self.viewModel.handle(action: .fetchReviews) + } + + // MARK: - Prepare UI + + func prepareUI() { + view.backgroundColor = .background + // add subviews + view.addSubview(imageView) + view.addSubview(titleLabel) + view.addSubview(popularityLabel) + view.addSubview(overviewLabel) + view.addSubview(collectionView) + setConstraints() + } + + // MARK: - Prepare Layout + + func collectionViewLayout() -> UICollectionViewLayout { + UICollectionViewCompositionalLayout.init { [unowned self] sectionIndex, _ in + + let section = self.sections[sectionIndex] + switch section { + case .reviewSection: + return getReviewCollectionLayoutSection() + default: + fatalError("Invalid section: \(section)") + } + } + } + + private func getReviewCollectionLayoutSection() -> NSCollectionLayoutSection { + let width = UIScreen.main.bounds.size.width - Layout.bannersSectionGroupWidthInset + + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(width), + heightDimension: .absolute(Layout.reviewGroupHeight) + ) + + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, repeatingSubitem: item, count: 1) + group.contentInsets.trailing = 16 + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .groupPaging + section.contentInsets.bottom = Layout.reviewSectionContentInsetBottom + return section + } + + // MARK: - Constraints + + func setConstraints() { + + let imageViewConstraints = [ + imageView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 4), + imageView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + imageView.heightAnchor.constraint(equalToConstant: Layout.bannerHeight) + ] + + NSLayoutConstraint.activate(imageViewConstraints) + + let titleLabelConstraints = [ + titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 8), + titleLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16) + ] + + NSLayoutConstraint.activate(titleLabelConstraints) + + let popularityLabelConstraints = [ + popularityLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + popularityLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + popularityLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16) + ] + + NSLayoutConstraint.activate(popularityLabelConstraints) + + let overviewLabelConstraints = [ + overviewLabel.topAnchor.constraint(equalTo: popularityLabel.bottomAnchor, constant: 8), + overviewLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 16), + overviewLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -16) + ] + + NSLayoutConstraint.activate(overviewLabelConstraints) + + let collectionViewConstraints = [ + collectionView.topAnchor.constraint(equalTo: overviewLabel.bottomAnchor, constant: 16), + collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8), + collectionView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: 8), + collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ] + + NSLayoutConstraint.activate(collectionViewConstraints) + + } + + // MARK: - Bind + + func bind() { + viewModel.statePublisher.compactMap(\.movie).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] movie in + guard let self else { return } + + self.titleLabel.text = movie.title + let formattedText = String(format: "%.2f", movie.voteAverage) + self.popularityLabel.text = "Vote Average: \(formattedText)" + self.overviewLabel.text = movie.overview + self.setImageView(nestedURL: movie.backdropPath) + + }).store(in: &cancellables) + + viewModel.statePublisher.compactMap(\.reviewList).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] _ in + guard let self else { return } + + self.releadList() + + }).store(in: &cancellables) + + viewModel.statePublisher.compactMap(\.error).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] error in + guard let self else { return } + + self.router?.showErrorAlert(message: error.localizedDescription) + + }).store(in: &cancellables) + + } + + func setImageView(nestedURL: String?) { + if let nestedURL = nestedURL { + do { + let url = try URL.getFullPath(sizeType: .poster(.w780) ,nestedURLString: nestedURL) + + ImageLoader.shared.loadImage(from: url).sinkToResult { result in + + guard case .success(let image) = result else { + return + } + self.imageView.image = image + + }.store(in: &self.cancellables) + } catch { + self.imageView.image = UIImage(named: "placeholder") + } + } + } + // MARK: - Actions + + func releadList(completion: (() -> Void)? = nil) { + collectionView.reloadData() + completion?() + } +} + +// MARK: - UICollectionViewDataSource + +extension MovieDetailViewController: UICollectionViewDataSource { + + func numberOfSections(in collectionView: UICollectionView) -> Int { + self.sections.count + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + + let section = self.sections[section] + + switch section { + case .reviewSection: + return self.viewModel.state.reviewList?.count ?? 0 + default: + return 0 + } + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + + let section = self.sections[indexPath.section] + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: self.sections[indexPath.section].cellReuseIdentifier, for: indexPath) + + switch section { + case .reviewSection: + + guard let reviewList = self.viewModel.state.reviewList else { return UICollectionViewCell() } + + cell.contentConfiguration = ReviewView.Configuration(author: reviewList[indexPath.row].author, description: reviewList[indexPath.row].content) + default: + return UICollectionViewCell() + } + return cell + } +} diff --git a/DarsayTechTest/Scenes/MovieDetail/MovieDetailViewModel.swift b/DarsayTechTest/Scenes/MovieDetail/MovieDetailViewModel.swift new file mode 100644 index 00000000..4034ab9f --- /dev/null +++ b/DarsayTechTest/Scenes/MovieDetail/MovieDetailViewModel.swift @@ -0,0 +1,67 @@ +// +// MovieDetailViewModel.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import Combine +import Foundation + +final class MovieDetailViewModel: SubjectedViewModel { + + typealias State = MovieDetail.State + + typealias Action = MovieDetail.Action + + let stateSubject: CurrentValueSubject + var cancellables = Set() + private var movieID: Int + let networkManager: MovieNetworkAPI + + init(configuration: MovieDetail.Configuration) { + stateSubject = .init(.init()) + movieID = configuration.movieID + networkManager = configuration.movieNetworkAPIManager + } + + func handle(action: Action) { + switch action { + case .fetchDetail: + fetchDetail() + case .fetchReviews: + fetchReviews() + } + } + + private func fetchDetail() { + networkManager.getMovieDetail(movieID: "\(self.movieID)").sinkToResult { [weak self] result in + guard let self else { return } + switch result { + case .success(let movie): + self.stateSubject.value.update({ + $0.movie = movie + }) + case .failure(let error): + self.stateSubject.value.update({ + $0.error = error + }) + } + }.store(in: &cancellables) + } + + private func fetchReviews() { + networkManager.getMovieReviews(movieID: "\(self.movieID)").sinkToResult { [weak self] result in + guard let self else { return } + switch result { + case .success(let list): + self.stateSubject.value.update({ + $0.reviewList = list.results + }) + case .failure(let error): + self.stateSubject.value.update({ + $0.error = error + }) + } + }.store(in: &cancellables) + } +} diff --git a/DarsayTechTest/Scenes/MovieDetail/Views/ReviewView.swift b/DarsayTechTest/Scenes/MovieDetail/Views/ReviewView.swift new file mode 100644 index 00000000..dd363005 --- /dev/null +++ b/DarsayTechTest/Scenes/MovieDetail/Views/ReviewView.swift @@ -0,0 +1,127 @@ +// +// ReviewView.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation +import UIKit +import Combine + +class ReviewView: UIView, UIContentView { + + // MARK: - Variables + var cancellables = Set() + + var configuration: UIContentConfiguration { + didSet { + updateData() + } + } + + lazy var containerView: UIView = { + let view = UIView() + view.layer.cornerRadius = 8 + view.clipsToBounds = true + view.backgroundColor = .highlightPositive + view.translatesAutoresizingMaskIntoConstraints = false + + return view + }() + + lazy var authorLabel: UILabel = { + let label = UILabel() + label.textColor = .onBackgroundPrimary + label.font.withSize(16) + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.textColor = .onHighlightPositive + label.numberOfLines = 0 + label.lineBreakMode = .byTruncatingTail + label.font.withSize(6) + label.translatesAutoresizingMaskIntoConstraints = false + + return label + }() + + // MARK: - Initialization + + init(configuration: Configuration) { + self.configuration = configuration + super.init(frame: .zero) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + + // add subviews here + self.addSubview(containerView) + containerView.addSubview(authorLabel) + containerView.addSubview(descriptionLabel) + + let containerViewConstraints = [ + containerView.topAnchor.constraint(equalTo: self.topAnchor), + containerView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + containerView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + containerView.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ] + + NSLayoutConstraint.activate(containerViewConstraints) + + let authorLabelConstraints = [ + authorLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4), + authorLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 4), + authorLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -4) + ] + + NSLayoutConstraint.activate(authorLabelConstraints) + + let descriptionLabelConstraints = [ + descriptionLabel.topAnchor.constraint(equalTo: authorLabel.bottomAnchor, constant: 4), + descriptionLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 4), + descriptionLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -4), + descriptionLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -4) + ] + + NSLayoutConstraint.activate(descriptionLabelConstraints) + + updateData() + } + + private func updateData() { + guard let configuration = configuration as? Configuration else { return } + authorLabel.text = configuration.author.isEmpty ? "" : "\(configuration.author):" + descriptionLabel.text = configuration.description + } + + // MARK: - Configuration + + struct Configuration: UIContentConfiguration { + + var author: String + var description: String + + init(author: String, description: String) { + self.author = author + self.description = description + } + + func makeContentView() -> UIView & UIContentView { + ReviewView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> ReviewView.Configuration { + return self + } + } +} diff --git a/DarsayTechTest/Scenes/MovieList/MovieListBuilder.swift b/DarsayTechTest/Scenes/MovieList/MovieListBuilder.swift new file mode 100644 index 00000000..a28ecb20 --- /dev/null +++ b/DarsayTechTest/Scenes/MovieList/MovieListBuilder.swift @@ -0,0 +1,30 @@ +// +// MovieListBuilder.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import UIKit + +enum MovieListBuilder: BaseSceneBuilder { + + typealias Config = MovieList.Configuration + + typealias SceneView = MovieListViewController + + static func build(with configuration: Config) -> SceneView { + + let viewModel = viewModelBuilder(configuration: configuration) + let router = MovieListRouter() + let viewController = SceneView(viewModel: viewModel, router: router) + + router.viewController = viewController + + return viewController + } + + private static func viewModelBuilder(configuration: Config) -> some ViewModel { + MovieListViewModel(configuration: configuration) + } + +} diff --git a/DarsayTechTest/Scenes/MovieList/MovieListModel.swift b/DarsayTechTest/Scenes/MovieList/MovieListModel.swift new file mode 100644 index 00000000..0639689b --- /dev/null +++ b/DarsayTechTest/Scenes/MovieList/MovieListModel.swift @@ -0,0 +1,25 @@ +// +// MovieListModel.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import Foundation + +enum MovieList { + + struct Configuration { + var movieNetworkAPIManager: MovieNetworkAPI + } + + struct State: StateProtocol { + var popularList: [Movie]? + var topRatedList: [Movie]? + var error: Error? + } + + enum Action { + case fetchPopularMovies + case fetchTopRatedMovies + } +} diff --git a/DarsayTechTest/Scenes/MovieList/MovieListRouter.swift b/DarsayTechTest/Scenes/MovieList/MovieListRouter.swift new file mode 100644 index 00000000..eab43ff5 --- /dev/null +++ b/DarsayTechTest/Scenes/MovieList/MovieListRouter.swift @@ -0,0 +1,39 @@ +// +// MovieListRouter.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import UIKit + +protocol MovieListRouterProtocol: AnyObject { + func routeToDetail(movieID: Int) + func showErrorAlert(message: String) + func routeToFavoriteList() +} + +class MovieListRouter: MovieListRouterProtocol { + + weak var viewController: MovieListViewController? + + func routeToDetail(movieID: Int) { + let destination = MovieDetailBuilder.build(with: .init(movieID: movieID, movieNetworkAPIManager: MovieNetworkAPIManager.shared)) + + self.viewController?.navigationController?.pushViewController(destination, animated: true) + } + + func showErrorAlert(message: String) { + let alertController = UIAlertController(title: LocalizeHelper.shared.lookup(.errorTitle), + message: message, + preferredStyle: .alert) + + alertController.addAction(UIAlertAction(title: LocalizeHelper.shared.lookup(.ok), style: .default, handler: nil)) + self.viewController?.present(alertController, animated: true) + } + + func routeToFavoriteList() { + let destination = FavoriteListBuilder.build(with: .init()) + + self.viewController?.navigationController?.pushViewController(destination, animated: true) + } +} diff --git a/DarsayTechTest/Scenes/MovieList/MovieListViewController.swift b/DarsayTechTest/Scenes/MovieList/MovieListViewController.swift new file mode 100644 index 00000000..f0b33bed --- /dev/null +++ b/DarsayTechTest/Scenes/MovieList/MovieListViewController.swift @@ -0,0 +1,317 @@ +// +// MovieListViewController.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import Combine +import Foundation +import UIKit + +fileprivate extension Layout { + + static let bannersSectionGroupWidthInset: CGFloat = 48 + static let contentScrollViewContentInsetBottom: CGFloat = 100 + static let popularSectionContentInsetBottom: CGFloat = 20 + static let topRatedGroupHeight: CGFloat = 150 + static let popularGroupHeight: CGFloat = 220 + static let headerHeight: CGFloat = 40 + static let headerReuseIdentifier = "header-identifier" +} + +fileprivate extension PageSection { + static let popularSection = Self.init(id: "section-popular") + static let topRatedSection = Self.init(id: "section-top-rated") +} + +class MovieListViewController: UIViewController, BaseSceneViewController { + + // MARK: - Variables + + let router: MovieListRouterProtocol? + let viewModel: any ViewModel + var cancellables = Set() + + var sections: [PageSection] { + [ .popularSection, + .topRatedSection + ] + } + // MARK: - Initialization + + init(viewModel: any ViewModel, router: MovieListRouterProtocol?) { + self.viewModel = viewModel + self.router = router + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError() + } + + // MARK: - UI Components + + private lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout()) + + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + collectionView.semanticContentAttribute = .forceLeftToRight + collectionView.backgroundColor = .background + collectionView.contentInset.bottom = Layout.contentScrollViewContentInsetBottom + collectionView.delegate = self + collectionView.dataSource = self + collectionView.translatesAutoresizingMaskIntoConstraints = false + + let cellReuseIdentifiers = [ + PageSection.topRatedSection.cellReuseIdentifier, + PageSection.popularSection.cellReuseIdentifier + ] + + for reuseIdentifier in cellReuseIdentifiers { + collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + } + + collectionView.register(SupplementaryHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: Layout.headerReuseIdentifier) + return collectionView + }() + + // MARK: - Bind + + func bind() { + viewModel.statePublisher.compactMap(\.popularList).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] _ in + guard let self else { return } + + self.releadList() + + }).store(in: &cancellables) + + viewModel.statePublisher.compactMap(\.topRatedList).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] _ in + guard let self else { return } + + self.releadList() + + }).store(in: &cancellables) + + viewModel.statePublisher.compactMap(\.error).receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] error in + guard let self else { return } + + self.router?.showErrorAlert(message: error.localizedDescription) + + }).store(in: &cancellables) + } + + // MARK: - Prepare UI + + func prepareUI() { + view.backgroundColor = .background + + let image = UIImage(symbolicName: .favoriteList, pointSize: 22) + + let barButtonItem = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(favoriteButtonTapped)) + barButtonItem.tintColor = .positiveGreen + navigationItem.leftBarButtonItem = barButtonItem + // add subviews + view.addSubview(collectionView) + setConstraints() + } + + // MARK: - Prepare Layout + + func collectionViewLayout() -> UICollectionViewLayout { + UICollectionViewCompositionalLayout.init { [unowned self] sectionIndex, _ in + + let section = self.sections[sectionIndex] + switch section { + case .popularSection: + return getPopularCollectionLayoutSection() + case .topRatedSection: + return getTopRatedCollectionLayoutSection() + default: + fatalError("Invalid section: \(section)") + } + } + } + + private func getPopularCollectionLayoutSection() -> NSCollectionLayoutSection { + let width = UIScreen.main.bounds.size.width - Layout.bannersSectionGroupWidthInset + + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) + + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(Layout.popularGroupHeight)) + + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, repeatingSubitem: item, count: 1) + group.contentInsets.trailing = 16 + group.contentInsets.top = 8 + + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), + heightDimension: .absolute(Layout.headerHeight)) + + let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top) + + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .groupPagingCentered + section.contentInsets.bottom = Layout.popularSectionContentInsetBottom + section.boundarySupplementaryItems = [header] + return section + } + + private func getTopRatedCollectionLayoutSection() -> NSCollectionLayoutSection { + + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) + + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.contentInsets = .init(top: 8, leading: 16, bottom: 8, trailing: 16) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(Layout.topRatedGroupHeight)) + + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 1) + + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), + heightDimension: .absolute(Layout.headerHeight)) + + let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top) + + let section = NSCollectionLayoutSection(group: group) + section.boundarySupplementaryItems = [header] + return section + } + + // MARK: - Constraints + + func setConstraints() { + + let constraints = [ + collectionView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + collectionView.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), + collectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ] + + NSLayoutConstraint.activate(constraints) + } + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + + prepareUI() + + bind() + + viewModel.handle(action: .fetchTopRatedMovies) + viewModel.handle(action: .fetchPopularMovies) + } + + // MARK: - Actions + + func releadList(completion: (() -> Void)? = nil) { + collectionView.reloadData() + completion?() + } + + @objc func favoriteButtonTapped() { + self.router?.routeToFavoriteList() + } +} + +// MARK: - UICollectionViewDelegate + +extension MovieListViewController: UICollectionViewDelegate { + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + let section = self.sections[indexPath.section] + + switch section { + case .popularSection: + + guard let movie = self.viewModel.state.popularList?[indexPath.row] else { return } + self.router?.routeToDetail(movieID: movie.id) + + case .topRatedSection: + + guard let movie = self.viewModel.state.topRatedList?[indexPath.row] else { return } + self.router?.routeToDetail(movieID: movie.id) + + default: + return + } + } +} + +// MARK: - UICollectionViewDataSource + +extension MovieListViewController: UICollectionViewDataSource { + + func numberOfSections(in collectionView: UICollectionView) -> Int { + self.sections.count + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + + let section = self.sections[section] + + switch section { + case .popularSection: + return self.viewModel.state.popularList?.count ?? 0 + case .topRatedSection: + return self.viewModel.state.topRatedList?.count ?? 0 + default: + return 0 + } + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + + let section = self.sections[indexPath.section] + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: self.sections[indexPath.section].cellReuseIdentifier, for: indexPath) + + switch section { + case .popularSection: + guard let movieList = self.viewModel.state.popularList else { return UICollectionViewCell() } + + cell.contentConfiguration = MovieBannerView.Configuration(movie: movieList[indexPath.row]) + case .topRatedSection: + guard let movieList = self.viewModel.state.topRatedList else { return UICollectionViewCell() } + + cell.contentConfiguration = MovieBannerView.Configuration(movie: movieList[indexPath.row]) + + default: + return UICollectionViewCell() + } + return cell + } + + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + + let section = self.sections[indexPath.section] + if kind == UICollectionView.elementKindSectionHeader { + + switch section { + case .popularSection: + if let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: Layout.headerReuseIdentifier, for: indexPath) as? SupplementaryHeaderView { + view.setText(LocalizeHelper.shared.lookup(.popularListTitle)) + return view + } + case .topRatedSection: + if let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: Layout.headerReuseIdentifier, for: indexPath) as? SupplementaryHeaderView { + view.setText(LocalizeHelper.shared.lookup(.topRatedListTitle)) + return view + } + + default: + return UICollectionViewCell() + } + + } + return UICollectionReusableView() + } +} diff --git a/DarsayTechTest/Scenes/MovieList/MovieListViewModel.swift b/DarsayTechTest/Scenes/MovieList/MovieListViewModel.swift new file mode 100644 index 00000000..099d69d8 --- /dev/null +++ b/DarsayTechTest/Scenes/MovieList/MovieListViewModel.swift @@ -0,0 +1,76 @@ +// +// MovieListViewModel.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. + +import Combine +import Foundation + +class MovieListViewModel: SubjectedViewModel { + + typealias State = MovieList.State + + typealias Action = MovieList.Action + + let stateSubject: CurrentValueSubject + let networkManager: MovieNetworkAPI + var cancellables = Set() + + init(configuration: MovieList.Configuration) { + stateSubject = .init(.init()) + networkManager = configuration.movieNetworkAPIManager + } + + func handle(action: Action) { + switch action { + case .fetchTopRatedMovies: + fetchTopRatedMovies() + case .fetchPopularMovies: + fetchPopularMovies() + } + } + + private func fetchTopRatedMovies() { + networkManager.getTopRatedMovies().sinkToResult { [weak self] result in + guard let self else { return } + switch result { + case .success(let movieList): + self.stateSubject.value.update({ + $0.topRatedList = self.syncFavoritMovies(list: movieList.results) + }) + case .failure(let error): + self.stateSubject.value.update({ + $0.error = error + }) + } + }.store(in: &cancellables) + } + + private func fetchPopularMovies(completion: (() -> Void)? = nil) { + + networkManager.getPopularMovies().sinkToResult { [weak self] result in + guard let self else { return } + switch result { + case .success(let movieList): + + self.stateSubject.value.update({ + $0.popularList = self.syncFavoritMovies(list: movieList.results) + }) + case .failure(let error): + self.stateSubject.value.update({ + $0.error = error + }) + } + completion?() + }.store(in: &cancellables) + } + + func syncFavoritMovies(list: [Movie]) -> [Movie] { + + for item in list { + item.isFaved = FavoriteStorage.shared.currentList.contains(item) + } + return list + } +} diff --git a/DarsayTechTest/Scenes/MovieList/Views/MovieBannerView.swift b/DarsayTechTest/Scenes/MovieList/Views/MovieBannerView.swift new file mode 100644 index 00000000..6dc01687 --- /dev/null +++ b/DarsayTechTest/Scenes/MovieList/Views/MovieBannerView.swift @@ -0,0 +1,200 @@ +// +// MovieBannerView.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation +import UIKit +import Combine + +class MovieBannerView: UIView, UIContentView { + + // MARK: - Variables + var cancellables = Set() + + var configuration: UIContentConfiguration { + didSet { + updateData() + } + } + + lazy var containerView: UIView = { + let view = UIView() + view.layer.cornerRadius = 8 + view.clipsToBounds = true + view.backgroundColor = .placeholderText + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var imageView: UIImageView = { + var imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + lazy var titleLabel: UILabel = { + let label = UILabel() + label.textColor = .black + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.font = UIFont.boldSystemFont(ofSize: 18.0) + label.backgroundColor = .lightText + label.alpha = 0.7 + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + lazy var favoriteButton: UIButton = { + let button = UIButton() + button.setImage(getProperImage(), for: .normal) + button.tintColor = .negative + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + // MARK: - Initialization + + init(configuration: Configuration) { + self.configuration = configuration + super.init(frame: .zero) + setupView() + + favoriteButton.addAction(.init(handler: { [weak self] _ in + guard let self = self else { return } + self.handleFavoriteAction() + }), for: .primaryActionTriggered) + + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + guard let configuration = configuration as? Configuration else { return } + + // add subviews here + self.addSubview(containerView) + containerView.addSubview(imageView) + containerView.addSubview(titleLabel) + + let containerViewConstraints = [ + containerView.topAnchor.constraint(equalTo: self.topAnchor), + containerView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + containerView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + containerView.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ] + + NSLayoutConstraint.activate(containerViewConstraints) + + let imageViewConstraints = [ + imageView.topAnchor.constraint(equalTo: containerView.topAnchor), + imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + imageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor) + ] + + NSLayoutConstraint.activate(imageViewConstraints) + + let titleConstraints = [ + titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + titleLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor) + ] + + NSLayoutConstraint.activate(titleConstraints) + + if configuration.hasFavoriteButton { + addFavorteButton() + } + + updateData() + } + + private func addFavorteButton() { + containerView.addSubview(favoriteButton) + + let favoriteConstraints = [ + favoriteButton.widthAnchor.constraint(equalToConstant: 40), + favoriteButton.heightAnchor.constraint(equalToConstant: 40), + favoriteButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 8), + favoriteButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor) + ] + + NSLayoutConstraint.activate(favoriteConstraints) + } + + private func updateData() { + + guard let configuration = configuration as? Configuration else { return } + + titleLabel.text = configuration.movie.title + updateImageView(nestedURLString: configuration.movie.backdropPath) + favoriteButton.setImage(getProperImage(), for: .normal) + } + + private func handleFavoriteAction() { + guard let configuration = configuration as? Configuration else { return } + + configuration.movie.isFaved = !(configuration.movie.isFaved ?? false) + + (configuration.movie.isFaved ?? false) ? FavoriteStorage.shared.append(movie: configuration.movie) : + FavoriteStorage.shared.remove(movie: configuration.movie) + + favoriteButton.setImage(getProperImage(), for: .normal) + } + + private func getProperImage() -> UIImage { + guard let configuration = configuration as? Configuration else { return UIImage() } + + let image = (configuration.movie.isFaved ?? false) ? UIImage(symbolicName: .filledFavorite, pointSize: 20) : UIImage(symbolicName: .favorite, pointSize: 20) + + return image ?? UIImage() + } + + private func updateImageView(nestedURLString: String?) { + do { + imageView.image = nil + let url = try URL.getFullPath(sizeType: .backDrop(.w300), nestedURLString: nestedURLString ?? "") + + ImageLoader.shared.loadImage(from: url).sinkToResult { result in + + guard case .success(let image) = result else { + self.imageView.image = nil + return + } + self.imageView.image = image + + }.store(in: &cancellables) + } catch { + self.imageView.image = nil + } + } + + // MARK: - Configuration + + final class Configuration: UIContentConfiguration { + + var movie: Movie + var hasFavoriteButton: Bool + + init(movie: Movie, hasFavoriteButton: Bool = true) { + self.movie = movie + self.hasFavoriteButton = hasFavoriteButton + } + + func makeContentView() -> UIView & UIContentView { + MovieBannerView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> MovieBannerView.Configuration { + return self + } + } +} diff --git a/DarsayTechTest/Scenes/MovieList/Views/SupplementaryHeaderView.swift b/DarsayTechTest/Scenes/MovieList/Views/SupplementaryHeaderView.swift new file mode 100644 index 00000000..18f5d027 --- /dev/null +++ b/DarsayTechTest/Scenes/MovieList/Views/SupplementaryHeaderView.swift @@ -0,0 +1,45 @@ +// +// SupplementaryHeaderView.swift +// DarsayTechTest +// +// Created by Farzaneh on 11/10/1401 AP. +// + +import Foundation +import UIKit + +class SupplementaryHeaderView: UICollectionReusableView { + + override init(frame: CGRect) { + super.init(frame: frame) + self.setupViews() + } + + required init(coder aDecoder: NSCoder) { + super.init(coder: aDecoder)! + self.setupViews() + } + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 20, weight: .heavy) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private func setupViews() { + self.addSubview(titleLabel) + + let titleLabelConstraints = [ + titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), + titleLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16), + titleLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -16) + ] + + NSLayoutConstraint.activate(titleLabelConstraints) + } + + func setText(_ text: String) { + titleLabel.text = text + } +} diff --git a/DarsayTechTestTests/ExecuterTest.swift b/DarsayTechTestTests/ExecuterTest.swift new file mode 100644 index 00000000..c5e56bd3 --- /dev/null +++ b/DarsayTechTestTests/ExecuterTest.swift @@ -0,0 +1,172 @@ +// +// ExecuterTest.swift +// DarsayTechTestTests +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import XCTest +@testable import DarsayTechTest +import Combine + +final class ExecuterTest: 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 testJsonExecuterWithPopularService() { + var cancellables = Set() + + let allJSONExecuter = MockHTTPRequestExecuter() + + let mockNetworkManager = MovieNetworkAPIManager(executer: allJSONExecuter) + + mockNetworkManager.getPopularMovies().sinkToResult { result in + + switch result { + case .success(let list): + XCTAssertEqual(list.results.count, 1) + case .failure(let error): + XCTFail(error.localizedDescription) + } + }.store(in: &cancellables) + } + + func testJsonExecuterWithTopRatedService() { + var cancellables = Set() + + let allJSONExecuter = MockHTTPRequestExecuter() + + let mockNetworkManager = MovieNetworkAPIManager(executer: allJSONExecuter) + + mockNetworkManager.getTopRatedMovies().sinkToResult { result in + + switch result { + case .success(let list): + XCTAssertEqual(list.results.count, 1) + case .failure(let error): + XCTFail(error.localizedDescription) + } + }.store(in: &cancellables) + } + + func testJsonExecuterWithMovieDetailService() { + var cancellables = Set() + + let allJSONExecuter = MockHTTPRequestExecuter() + + let mockNetworkManager = MovieNetworkAPIManager(executer: allJSONExecuter) + + mockNetworkManager.getMovieDetail(movieID: "").sinkToResult { result in + + switch result { + case .success(let movie): + XCTAssertEqual(movie.originalLanguage, "en") + case .failure(let error): + XCTFail(error.localizedDescription) + } + }.store(in: &cancellables) + } + + func testJsonExecuterWithMovieReview() { + var cancellables = Set() + + let allJSONExecuter = MockHTTPRequestExecuter() + + let mockNetworkManager = MovieNetworkAPIManager(executer: allJSONExecuter) + + mockNetworkManager.getMovieReviews(movieID: "").sinkToResult { result in + + switch result { + case .success(let list): + XCTAssertEqual(list.results.count, 1) + case .failure(let error): + XCTFail(error.localizedDescription) + } + }.store(in: &cancellables) + } + + //================================ Test FailExecuter ====================================// + + func testFailExecuterWithMovieListVCForFetchPopular() { + let allFailure = MockAllFailureHTTPRequestExecuter() + + let mockNetworkManager = MovieNetworkAPIManager(executer: allFailure) + + let viewController = MovieListBuilder.build(with: .init(movieNetworkAPIManager: mockNetworkManager)) + + viewController.viewModel.handle(action: .fetchPopularMovies) + let expectation = expectation(description: "Fetch data from service") + + viewController.releadList { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertNotNil(viewController.viewModel.state.error) + } + + func testFailExecuterWithMovieListVCForFetchTopRated() { + let allFailure = MockAllFailureHTTPRequestExecuter() + + let mockNetworkManager = MovieNetworkAPIManager(executer: allFailure) + + let viewController = MovieListBuilder.build(with: .init(movieNetworkAPIManager: mockNetworkManager)) + + viewController.viewModel.handle(action: .fetchTopRatedMovies) + let expectation = expectation(description: "Fetch data from service") + + viewController.releadList { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertNotNil(viewController.viewModel.state.error) + } + + func testFailExecuterWithDetailVCForFetchDetail() { + let allFailure = MockAllFailureHTTPRequestExecuter() + + let mockNetworkManager = MovieNetworkAPIManager(executer: allFailure) + + let viewController = MovieDetailBuilder.build(with: .init(movieID: 0, movieNetworkAPIManager: mockNetworkManager)) + + viewController.viewModel.handle(action: .fetchDetail) + let expectation = expectation(description: "Fetch data from service") + + viewController.releadList { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertNotNil(viewController.viewModel.state.error) + } + + func testFailExecuterWithDetailVCForFetchReviews() { + let allFailure = MockAllFailureHTTPRequestExecuter() + + let mockNetworkManager = MovieNetworkAPIManager(executer: allFailure) + + let viewController = MovieDetailBuilder.build(with: .init(movieID: 0, movieNetworkAPIManager: mockNetworkManager)) + + viewController.viewModel.handle(action: .fetchReviews) + let expectation = expectation(description: "Fetch data from service") + + viewController.releadList { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertNotNil(viewController.viewModel.state.error) + } + +} diff --git a/DarsayTechTestTests/JSON/detail_json.json b/DarsayTechTestTests/JSON/detail_json.json new file mode 100644 index 00000000..1755e1f8 --- /dev/null +++ b/DarsayTechTestTests/JSON/detail_json.json @@ -0,0 +1,55 @@ +{ + "adult": false, + "backdrop_path": "/fCayJrkfRaCRCTh8GqN30f8oyQF.jpg", + "belongs_to_collection": null, + "budget": 63000000, + "genres": [ + { + "id": 18, + "name": "Drama" + } + ], + "homepage": "", + "id": 550, + "imdb_id": "tt0137523", + "original_language": "en", + "original_title": "Fight Club", + "overview": "A ticking-time-bomb insomniac and a slippery soap salesman channel primal male aggression into a shocking new form of therapy. Their concept catches on, with underground \"fight clubs\" forming in every town, until an eccentric gets in the way and ignites an out-of-control spiral toward oblivion.", + "popularity": 0.5, + "poster_path": null, + "production_companies": [ + { + "id": 711, + "logo_path": null, + "name": "Fox 2000 Pictures", + "origin_country": "" + }, + { + "id": 54050, + "logo_path": null, + "name": "Linson Films", + "origin_country": "" + } + ], + "production_countries": [ + { + "iso_3166_1": "US", + "name": "United States of America" + } + ], + "release_date": "1999-10-12", + "revenue": 100853753, + "runtime": 139, + "spoken_languages": [ + { + "iso_639_1": "en", + "name": "English" + } + ], + "status": "Released", + "tagline": "How much can you know about yourself if you've never been in a fight?", + "title": "Fight Club", + "video": false, + "vote_average": 7.8, + "vote_count": 3439 +} diff --git a/DarsayTechTestTests/JSON/most_popular_json.json b/DarsayTechTestTests/JSON/most_popular_json.json new file mode 100644 index 00000000..3c86e775 --- /dev/null +++ b/DarsayTechTestTests/JSON/most_popular_json.json @@ -0,0 +1,26 @@ +{ +"page": 1, + "results": [ + { + "poster_path": "/lFSSLTlFozwpaGlO31OoUeirBgQ.jpg", + "adult": false, + "overview": "The most dangerous former operative of the CIA is drawn out of hiding to uncover hidden truths about his past.", + "release_date": "2016-07-27", + "genre_ids": [ + 28, + 53 + ], + "id": 324668, + "original_title": "Sample title for most popular movie", + "original_language": "en", + "title": "Sample title for most popular movie", + "backdrop_path": "/AoT2YrJUJlg5vKE3iMOLvHlTd3m.jpg", + "popularity": 30.690177, + "vote_count": 649, + "video": false, + "vote_average": 5.25 + } + ], + "total_results": 19629, + "total_pages": 982 +} diff --git a/DarsayTechTestTests/JSON/review_json.json b/DarsayTechTestTests/JSON/review_json.json new file mode 100644 index 00000000..1936ee4c --- /dev/null +++ b/DarsayTechTestTests/JSON/review_json.json @@ -0,0 +1,22 @@ +{ + "id": 11, + "page": 1, + "results": [ + { + "author": "Me", + "author_details": { + "name": "Cat Ellington", + "username": "Test", + "avatar_path": "/uCmwgSbJAcHqNwSvQvTv2dB95tx.jpg", + "rating": null + }, + "content": "(As I'm writing this review, Darth Vader's theme music begins to build in my mind...)\r\n\r\nWell, it actually has a title, what the Darth Vader theme. And that title is \"The Imperial March\", composed by the great John Williams, whom, as many of you may already know, also composed the theme music for \"Jaws\" - that legendary score simply titled, \"Main Title (Theme From Jaws)\".\r\n\r\nNow, with that lil' bit of trivia aside, let us procede with the fabled film currently under review: Star Wars.", + "created_at": "2017-02-13T22:23:01.268Z", + "id": "58a231c5925141179e000674", + "updated_at": "2017-02-13T23:16:19.538Z", + "url": "https://www.themoviedb.org/review/58a231c5925141179e000674" + } + ], + "total_pages": 1, + "total_results": 3 +} diff --git a/DarsayTechTestTests/JSON/top_rated_json.json b/DarsayTechTestTests/JSON/top_rated_json.json new file mode 100644 index 00000000..9ee9bb9d --- /dev/null +++ b/DarsayTechTestTests/JSON/top_rated_json.json @@ -0,0 +1,26 @@ +{ + "page": 1, + "results": [ + { + "poster_path": "/lIv1QinFqz4dlp5U4lQ6HaiskOZ.jpg", + "adult": false, + "overview": "Under the direction of a ruthless instructor, a talented young drummer begins to pursue perfection at any cost, even his humanity.", + "release_date": "2014-10-10", + "genre_ids": [ + 18, + 10402 + ], + "id": 244786, + "original_title": "Sample Title", + "original_language": "fa", + "title": "Sample Title", + "backdrop_path": "/6bbZ6XyvgfjhQwbplnUh1LSj1ky.jpg", + "popularity": 10.776056, + "vote_count": 2059, + "video": false, + "vote_average": 8.29 + } + ], + "total_pages": 84, + "total_results": 970 +} diff --git a/DarsayTechTestTests/JSONLoader.swift b/DarsayTechTestTests/JSONLoader.swift new file mode 100644 index 00000000..166597f4 --- /dev/null +++ b/DarsayTechTestTests/JSONLoader.swift @@ -0,0 +1,36 @@ +// +// JSONLoader.swift +// DarsayTechTestTests +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation +import XCTest +@testable import DarsayTechTest + +protocol JSONLoader: AnyObject { + var bundle: Bundle { get } + func loadJSON(filename: String, type: T.Type) throws -> T +} + +extension JSONLoader { + var bundle: Bundle { + return Bundle(for: type(of: self)) + } + + func loadJSON(filename: String, type: T.Type) throws -> T { + guard let path = bundle.url(forResource: filename, withExtension: "json") else { + throw NetworkError.notFound + } + + do { + let data = try Data(contentsOf: path) + let decodedObject = try JSONDecoder().decode(type, from: data) + + return decodedObject + } catch { + throw NetworkError.decodeFailed + } + } +} diff --git a/DarsayTechTestTests/LoadableJSONTest.swift b/DarsayTechTestTests/LoadableJSONTest.swift new file mode 100644 index 00000000..76cdd5b8 --- /dev/null +++ b/DarsayTechTestTests/LoadableJSONTest.swift @@ -0,0 +1,86 @@ +// +// LoadableJSONTest.swift +// DarsayTechTestTests +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import XCTest +@testable import DarsayTechTest +import Combine + +final class LoadableJSONTest: 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 testLoadableJSONwithPopular() { + + let mockService = MockService() + var cancellables = Set() + + mockService.getPopularMovies().sinkToResult { result in + + switch result { + case .success(let list): + XCTAssertEqual(list.results.count, 1) + case .failure(let error): + XCTFail(error.localizedDescription) + } + }.store(in: &cancellables) + } + + func testLoadableJSONwithTopRated() { + + let mockService = MockService() + var cancellables = Set() + + mockService.getTopRatedMovies().sinkToResult { result in + + switch result { + case .success(let list): + XCTAssertEqual(list.results.count, 1) + case .failure(let error): + XCTFail(error.localizedDescription) + } + }.store(in: &cancellables) + } + + func testLoadableJSONwithDetail() { + + let mockService = MockService() + var cancellables = Set() + + mockService.getMovieDetail(movieID: "").sinkToResult { result in + + switch result { + case .success(let movie): + XCTAssertEqual(movie.originalLanguage, "en") + case .failure(let error): + XCTFail(error.localizedDescription) + } + }.store(in: &cancellables) + } + + func testLoadableJSONwithReview() { + + let mockService = MockService() + var cancellables = Set() + + mockService.getMovieReviews(movieID: "").sinkToResult { result in + + switch result { + case .success(let list): + XCTAssertEqual(list.results.first?.author, "Me") + case .failure(let error): + XCTFail(error.localizedDescription) + } + }.store(in: &cancellables) + } + +} diff --git a/DarsayTechTestTests/MockExecuter.swift b/DarsayTechTestTests/MockExecuter.swift new file mode 100644 index 00000000..f673eb56 --- /dev/null +++ b/DarsayTechTestTests/MockExecuter.swift @@ -0,0 +1,43 @@ +// +// MockExecuter.swift +// DarsayTechTestTests +// +// Created by Farzaneh on 11/8/1401 AP. +//swiftlint:disable force_cast + +import Foundation +@testable import DarsayTechTest +import Combine + +final class MockAllFailureHTTPRequestExecuter: HTTPRequestProtocol { + + func performRequest(endpoint: EndPointTarget, responseModel: T.Type, cachedResponseOnError: Bool) -> AnyPublisher { + Fail(outputType: T.self, failure: NetworkError.generalError).eraseToAnyPublisher() + } +} + +final class MockHTTPRequestExecuter: HTTPRequestProtocol { + func performRequest(endpoint: EndPointTarget, responseModel: T.Type, cachedResponseOnError: Bool) -> AnyPublisher { + + guard let endpoint = endpoint as? MovieServiceEndPoint else { + return Fail(outputType: T.self, failure: NetworkError.badURL).eraseToAnyPublisher() + } + + let mockService = MockService() + + switch endpoint { + + case .getTopRated where T.self == ResultList.self: + return mockService.getTopRatedMovies() as! AnyPublisher + case .getReviews(let id) where T.self == ResultList.self: + return mockService.getMovieReviews(movieID: id) as! AnyPublisher + case .getDetail(let id) where T.self == Movie.self: + return mockService.getMovieDetail(movieID: id) as! AnyPublisher + case .getMostPopular where T.self == ResultList.self: + return mockService.getPopularMovies() as! AnyPublisher + + default: + return Fail(outputType: T.self, failure: NetworkError.serverError).eraseToAnyPublisher() + } + } +} diff --git a/DarsayTechTestTests/MockService.swift b/DarsayTechTestTests/MockService.swift new file mode 100644 index 00000000..7c0a0c70 --- /dev/null +++ b/DarsayTechTestTests/MockService.swift @@ -0,0 +1,51 @@ +// +// MockService.swift +// DarsayTechTestTests +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import XCTest +import Combine +@testable import DarsayTechTest + +final class MockService: MovieNetworkAPI, JSONLoader { + + func getPopularMovies() -> AnyPublisher, Error> { + + do { + let publisher = Just(try loadJSON(filename: "most_popular_json", type: ResultList.self)) + return publisher.setFailureType(to: Error.self).eraseToAnyPublisher() + } catch { + return Fail(outputType: ResultList.self, failure: error).eraseToAnyPublisher() + } + } + + func getTopRatedMovies() -> AnyPublisher, Error> { + do { + let publisher = Just(try loadJSON(filename: "top_rated_json", type: ResultList.self)) + return publisher.setFailureType(to: Error.self).eraseToAnyPublisher() + } catch { + return Fail(outputType: ResultList.self, failure: error).eraseToAnyPublisher() + } + } + + func getMovieDetail(movieID: String) -> AnyPublisher { + do { + let publisher = Just(try loadJSON(filename: "detail_json", type: Movie.self)) + return publisher.setFailureType(to: Error.self).eraseToAnyPublisher() + } catch { + return Fail(outputType: Movie.self, failure: error).eraseToAnyPublisher() + } + } + + func getMovieReviews(movieID: String) -> AnyPublisher, Error> { + do { + let publisher = Just(try loadJSON(filename: "review_json", type: ResultList.self)) + return publisher.setFailureType(to: Error.self).eraseToAnyPublisher() + } catch { + return Fail(outputType: ResultList.self, failure: error).eraseToAnyPublisher() + } + + } +} diff --git a/DarsayTechTestTests/StorageTest.swift b/DarsayTechTestTests/StorageTest.swift new file mode 100644 index 00000000..0d80c2fe --- /dev/null +++ b/DarsayTechTestTests/StorageTest.swift @@ -0,0 +1,53 @@ +// +// StorageTest.swift +// DarsayTechTestTests +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import Foundation +import Combine +import XCTest +@testable import DarsayTechTest + +final class StorageTest: 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 testStorage() { + + let mockService = MockService() + var cancellables = Set() + + let mockStorage = FavoriteStorage.shared + + mockService.getPopularMovies().sinkToResult { result in + + switch result { + case .success(let list): + XCTAssertEqual(list.results.count, 1) + + mockStorage.setObject(object: list.results) + + var retreivedList = mockStorage.getObject() + + XCTAssertEqual(list.results, retreivedList) + + mockStorage.remove() + + retreivedList = mockStorage.getObject() + + XCTAssertNil(retreivedList) + + case .failure(let error): + XCTFail(error.localizedDescription) + } + }.store(in: &cancellables) + } +} diff --git a/DarsayTechTestTests/ViewControllerTest.swift b/DarsayTechTestTests/ViewControllerTest.swift new file mode 100644 index 00000000..1e6c2dfe --- /dev/null +++ b/DarsayTechTestTests/ViewControllerTest.swift @@ -0,0 +1,95 @@ +// +// DarsayTechTestTests.swift +// DarsayTechTestTests +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import XCTest +@testable import DarsayTechTest + +final class DarsayTechTestTests: 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 testMovieListViewControllerFetchTopRated() throws { + + let mockService = MockService() + + let viewController = MovieListBuilder.build(with: .init(movieNetworkAPIManager: mockService)) + + viewController.viewModel.handle(action: .fetchTopRatedMovies) + let expectation = expectation(description: "Fetch data from service") + + viewController.releadList { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertEqual(viewController.viewModel.state.topRatedList?.count, 1) + XCTAssertEqual(viewController.viewModel.state.topRatedList?.first?.originalLanguage, "fa") + } + + func testMovieListViewControllerFetchPopular() throws { + + let mockService = MockService() + + let viewController = MovieListBuilder.build(with: .init(movieNetworkAPIManager: mockService)) + + viewController.viewModel.handle(action: .fetchPopularMovies) + let expectation = expectation(description: "Fetch data from service") + + viewController.releadList { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertEqual(viewController.viewModel.state.popularList?.count, 1) + XCTAssertEqual(viewController.viewModel.state.popularList?.first?.title, "Sample title for most popular movie") + } + + func testMovieDetailViewControllerFetchDetail() throws { + + let mockService = MockService() + + let viewController = MovieDetailBuilder.build(with: .init(movieID: 0, movieNetworkAPIManager: mockService)) + + viewController.viewModel.handle(action: .fetchDetail) + let expectation = expectation(description: "Fetch data from service") + + viewController.releadList { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertNotNil(viewController.viewModel.state.movie) + } + + func testMovieDetailViewControllerFetchReview() throws { + + let mockService = MockService() + + let viewController = MovieDetailBuilder.build(with: .init(movieID: 0, movieNetworkAPIManager: mockService)) + + viewController.viewModel.handle(action: .fetchReviews) + let expectation = expectation(description: "Fetch data from service") + + viewController.releadList { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 10.0) + + XCTAssertEqual(viewController.viewModel.state.reviewList?.first?.author, "Me") + } + +} diff --git a/DarsayTechTestTests/ViewModelTest.swift b/DarsayTechTestTests/ViewModelTest.swift new file mode 100644 index 00000000..d60a4f75 --- /dev/null +++ b/DarsayTechTestTests/ViewModelTest.swift @@ -0,0 +1,91 @@ +// +// ViewModelTest.swift +// DarsayTechTestTests +// +// Created by Farzaneh on 11/9/1401 AP. +// + +import XCTest +@testable import DarsayTechTest +import Combine +final class ViewModelTest: 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 testFavoriteViewModel() { + + let favoriteViewModel = FavoriteListViewModel(configuration: .init()) + + if FavoriteStorage.shared.currentList.isEmpty { + XCTAssertEqual(favoriteViewModel.state.favoriteList, nil) + } else { + XCTAssertEqual(favoriteViewModel.state.favoriteList?.count, favoriteViewModel.state.favoriteList?.count) + } + } + + func testMovieListViewModelFetchTopRated() throws { + + let mockService = MockService() + + let viewModel = MovieListViewModel(configuration: .init(movieNetworkAPIManager: mockService)) + + let statePublisher = viewModel.statePublisher.collect(1).first() + viewModel.handle(action: .fetchTopRatedMovies) + + let state = try awaitPublisher(statePublisher).last + + XCTAssertEqual(state?.topRatedList?.count, 1) + XCTAssertEqual(state?.topRatedList?.first?.originalLanguage, "fa") + } + + func testMovieListViewControllerFetchPopular() throws { + + let mockService = MockService() + + let viewModel = MovieListViewModel(configuration: .init(movieNetworkAPIManager: mockService)) + + let statePublisher = viewModel.statePublisher.collect(1).first() + viewModel.handle(action: .fetchPopularMovies) + + let state = try awaitPublisher(statePublisher).last + + XCTAssertEqual(state?.popularList?.count, 1) + XCTAssertEqual(state?.popularList?.first?.title, "Sample title for most popular movie") + } + + func testMovieDetailViewControllerFetchDetail() throws { + + let mockService = MockService() + + let viewModel = MovieDetailViewModel(configuration: .init(movieID: 0, movieNetworkAPIManager: mockService)) + + let statePublisher = viewModel.statePublisher.collect(1).first() + viewModel.handle(action: .fetchDetail) + + let state = try awaitPublisher(statePublisher).last + + XCTAssertNotNil(state?.movie) + } + + func testMovieDetailViewControllerFetchReview() throws { + + let mockService = MockService() + + let viewModel = MovieDetailViewModel(configuration: .init(movieID: 0, movieNetworkAPIManager: mockService)) + + let statePublisher = viewModel.statePublisher.collect(1).first() + + viewModel.handle(action: .fetchReviews) + + let state = try awaitPublisher(statePublisher).last + + XCTAssertEqual(state?.reviewList?.first?.author, "Me") + } + +} diff --git a/DarsayTechTestTests/XCTest+Extension.swift b/DarsayTechTestTests/XCTest+Extension.swift new file mode 100644 index 00000000..077afa34 --- /dev/null +++ b/DarsayTechTestTests/XCTest+Extension.swift @@ -0,0 +1,64 @@ +// +// XCTest+Extension.swift +// DarsayTechTestTests +// +// Created by Farzaneh on 11/9/1401 AP. +// + +import Foundation +import Combine +import XCTest +@testable import DarsayTechTest + +extension XCTestCase { + func awaitPublisher( + _ publisher: T, + timeout: TimeInterval = 10, + message: String? = nil, + function: StaticString = #function, + file: StaticString = #file, + line: UInt = #line + ) throws -> T.Output { + let message = message ?? "Awaited publisher did not produce any output for \(function)" + + // This time, we use Swift's Result type to keep track + // of the result of our Combine pipeline: + var result: Result? + let expectation = self.expectation(description: "Awaiting publisher \(function)") + + let cancellable = publisher.sink( + receiveCompletion: { completion in + switch completion { + case .failure(let error): + result = .failure(error) + case .finished: + break + } + + expectation.fulfill() + }, + receiveValue: { value in + result = .success(value) + } + ) + + // Just like before, we await the expectation that we + // created at the top of our test, and once done, we + // also cancel our cancellable to avoid getting any + // unused variable warnings: + waitForExpectations(timeout: timeout) + cancellable.cancel() + + // Here we pass the original file and line number that + // our utility was called at, to tell XCTest to report + // any encountered errors at that original call site: + let unwrappedResult = try XCTUnwrap( + result, + message, + file: file, + line: line + ) + + return try unwrappedResult.get() + } +} diff --git a/DarsayTechTestUITests/DarsayTechTestUITests.swift b/DarsayTechTestUITests/DarsayTechTestUITests.swift new file mode 100644 index 00000000..512ac129 --- /dev/null +++ b/DarsayTechTestUITests/DarsayTechTestUITests.swift @@ -0,0 +1,41 @@ +// +// DarsayTechTestUITests.swift +// DarsayTechTestUITests +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import XCTest + +final class DarsayTechTestUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/DarsayTechTestUITests/DarsayTechTestUITestsLaunchTests.swift b/DarsayTechTestUITests/DarsayTechTestUITestsLaunchTests.swift new file mode 100644 index 00000000..7fdc036c --- /dev/null +++ b/DarsayTechTestUITests/DarsayTechTestUITestsLaunchTests.swift @@ -0,0 +1,32 @@ +// +// DarsayTechTestUITestsLaunchTests.swift +// DarsayTechTestUITests +// +// Created by Farzaneh on 11/8/1401 AP. +// + +import XCTest + +final class DarsayTechTestUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Scripts/swiftlint.sh b/Scripts/swiftlint.sh new file mode 100755 index 00000000..6f7c6453 --- /dev/null +++ b/Scripts/swiftlint.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +if test -d "/opt/homebrew/bin/"; then + PATH="/opt/homebrew/bin/:${PATH}" +fi +export PATH + +if which swiftlint >/dev/null; then + swiftlint +else + echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint" +fi