├── ExampleMVVM.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── oleh.kudinov.xcuserdatad │ │ ├── IDEFindNavigatorScopes.plist │ │ └── UserInterfaceState.xcuserstate ├── xcshareddata │ └── xcschemes │ │ └── ExampleMVVM.xcscheme └── xcuserdata │ └── oleh.kudinov.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── ExampleMVVM ├── Application │ ├── AppAppearance.swift │ ├── AppConfigurations.swift │ ├── AppDIContainer.swift │ ├── AppDelegate.swift │ └── AppMainCoordinator.swift ├── Common │ └── Cancellable.swift ├── Data │ ├── Network │ │ ├── APIEndpoints.swift │ │ └── DataMapping │ │ │ └── Movie+Mapping.swift │ ├── PersistentStorages │ │ └── MoviesRecentQueriesStorage.swift │ └── Repositories │ │ ├── MoviesDataSource.swift │ │ ├── MoviesQueriesDataSource.swift │ │ └── PosterImagesDataSource.swift ├── Domain │ ├── Entities │ │ ├── Movie.swift │ │ ├── MovieOffer.swift │ │ └── MovieQuery.swift │ ├── Interfaces │ │ └── Repositories │ │ │ ├── MoviesDataSourceInterface.swift │ │ │ ├── MoviesQueriesDataSourceInterface.swift │ │ │ └── PosterImagesDataSourceInterface.swift │ └── UseCases │ │ ├── FetchMovieOfferUseCase.swift │ │ └── FetchMoviesUseCase.swift ├── Infrastructure │ └── Network │ │ ├── DataTransfer.swift │ │ ├── Endpoint.swift │ │ ├── Network.swift │ │ └── NetworkConfig.swift ├── Presentation │ ├── MoviesScene │ │ ├── MoviesList │ │ │ ├── Coordinator │ │ │ │ └── MoviesListViewCoordinator.swift │ │ │ ├── View │ │ │ │ ├── MoviesListTableView │ │ │ │ │ ├── Cells │ │ │ │ │ │ └── MoviesListItemCell.swift │ │ │ │ │ └── MoviesListTableViewController.swift │ │ │ │ ├── MoviesListViewController.storyboard │ │ │ │ └── MoviesListViewController.swift │ │ │ └── ViewModel │ │ │ │ ├── MoviesListViewItemModel.swift │ │ │ │ └── MoviesListViewModel.swift │ │ ├── MoviesQueriesList │ │ │ ├── Coordinator │ │ │ │ └── MoviesQueriesListCoordinator.swift │ │ │ ├── View │ │ │ │ ├── Cells │ │ │ │ │ └── MoviesQueriesItemCell.swift │ │ │ │ ├── MoviesQueriesTableViewController.storyboard │ │ │ │ └── MoviesQueriesTableViewController.swift │ │ │ └── ViewModel │ │ │ │ └── MoviesQueriesListViewModel.swift │ │ └── MoviesSceneDIContainer.swift │ └── Utils │ │ ├── MVVM │ │ ├── MVVMView.swift │ │ └── MVVMViewModel.swift │ │ ├── Observable.swift │ │ └── Protocols │ │ ├── Alertable.swift │ │ ├── Coordinator.swift │ │ └── StoryboardInstantiable.swift └── Resources │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ └── image_not_found.imageset │ │ ├── Contents.json │ │ └── image_not_found.png │ ├── Base.lproj │ └── LaunchScreen.storyboard │ └── Info.plist ├── ExampleMVVMTests ├── FetchMoviesUseCaseTests.swift └── Info.plist └── README.md /ExampleMVVM.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 48; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 1F05A6CC2220A2CB001E2801 /* FetchMoviesUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F05A6CB2220A2CB001E2801 /* FetchMoviesUseCase.swift */; }; 11 | 1F1CD656222368CA00B0143C /* AppConfigurations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F1CD655222368CA00B0143C /* AppConfigurations.swift */; }; 12 | 1F1FC49F22E61EA800BCBA8D /* Network.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F1FC49B22E61EA800BCBA8D /* Network.swift */; }; 13 | 1F1FC4A022E61EA800BCBA8D /* DataTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F1FC49C22E61EA800BCBA8D /* DataTransfer.swift */; }; 14 | 1F1FC4A122E61EA800BCBA8D /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F1FC49D22E61EA800BCBA8D /* Endpoint.swift */; }; 15 | 1F1FC4A222E61EA800BCBA8D /* NetworkConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F1FC49E22E61EA800BCBA8D /* NetworkConfig.swift */; }; 16 | 1F326C3822B8163A00154226 /* FetchMovieOfferUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F326C3722B8163A00154226 /* FetchMovieOfferUseCase.swift */; }; 17 | 1F326C3A22B8169800154226 /* MovieOffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F326C3922B8169800154226 /* MovieOffer.swift */; }; 18 | 1F326C4322B8243800154226 /* MVVMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F326C4122B8243800154226 /* MVVMView.swift */; }; 19 | 1F326C4422B8243800154226 /* MVVMViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F326C4222B8243800154226 /* MVVMViewModel.swift */; }; 20 | 1F474F2522356B690092DB4B /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F474F2422356B690092DB4B /* Cancellable.swift */; }; 21 | 1F474F3122356CDD0092DB4B /* FetchMoviesUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC83B221B0299007D99D2 /* FetchMoviesUseCaseTests.swift */; }; 22 | 1F474F3222356CEC0092DB4B /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA533B1201EE2A500747E55 /* Movie.swift */; }; 23 | 1F474F3322356CEC0092DB4B /* MovieQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7408192165574400FE52A5 /* MovieQuery.swift */; }; 24 | 1F474F3522356CEC0092DB4B /* FetchMoviesUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F05A6CB2220A2CB001E2801 /* FetchMoviesUseCase.swift */; }; 25 | 1F474F3622356CEC0092DB4B /* MoviesDataSourceInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC830221B0041007D99D2 /* MoviesDataSourceInterface.swift */; }; 26 | 1F474F3722356CEC0092DB4B /* PosterImagesDataSourceInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC82C221B0041007D99D2 /* PosterImagesDataSourceInterface.swift */; }; 27 | 1F474F3822356CEC0092DB4B /* MoviesQueriesDataSourceInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC828221B0041007D99D2 /* MoviesQueriesDataSourceInterface.swift */; }; 28 | 1F474F3A22356CEC0092DB4B /* Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F474F2422356B690092DB4B /* Cancellable.swift */; }; 29 | 1F6EA7AC22B6A6470075D7C0 /* APIEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6EA7A922B6A6470075D7C0 /* APIEndpoints.swift */; }; 30 | 1F6EA7AD22B6A6470075D7C0 /* Movie+Mapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6EA7AB22B6A6470075D7C0 /* Movie+Mapping.swift */; }; 31 | 1F77930F222C0DF2004E034C /* StoryboardInstantiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F77930E222C0DF2004E034C /* StoryboardInstantiable.swift */; }; 32 | 1FA5338D201E1FDE00747E55 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA5338C201E1FDE00747E55 /* AppDelegate.swift */; }; 33 | 1FA53394201E1FDE00747E55 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1FA53393201E1FDE00747E55 /* Assets.xcassets */; }; 34 | 1FA53397201E1FDE00747E55 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1FA53395201E1FDE00747E55 /* LaunchScreen.storyboard */; }; 35 | 1FA533B2201EE2A500747E55 /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA533B1201EE2A500747E55 /* Movie.swift */; }; 36 | 1FA533D7201EF2B500747E55 /* AppDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA533D6201EF2B500747E55 /* AppDIContainer.swift */; }; 37 | 1FB2B480222C10A900C9D095 /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB2B47F222C10A900C9D095 /* Coordinator.swift */; }; 38 | 1FB2B482222C10FC00C9D095 /* AppMainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB2B481222C10FC00C9D095 /* AppMainCoordinator.swift */; }; 39 | 1FB2B484222C233D00C9D095 /* MoviesQueriesListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB2B483222C233D00C9D095 /* MoviesQueriesListCoordinator.swift */; }; 40 | 1FB2B489222C5FB200C9D095 /* MoviesListViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB2B488222C5FB200C9D095 /* MoviesListViewCoordinator.swift */; }; 41 | 1FCE689E222C857B00CC3074 /* MoviesSceneDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FCE689D222C857B00CC3074 /* MoviesSceneDIContainer.swift */; }; 42 | 1FEE31582217282000C160B9 /* MoviesQueriesItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FEE314B2217282000C160B9 /* MoviesQueriesItemCell.swift */; }; 43 | 1FEE31592217282000C160B9 /* MoviesQueriesTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FEE314C2217282000C160B9 /* MoviesQueriesTableViewController.swift */; }; 44 | 1FEE315A2217282000C160B9 /* MoviesQueriesTableViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1FEE314D2217282000C160B9 /* MoviesQueriesTableViewController.storyboard */; }; 45 | 1FEE31622218B17E00C160B9 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FEE31612218B17E00C160B9 /* Observable.swift */; }; 46 | 1FEE3164221ABE3400C160B9 /* MoviesListViewItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FEE3163221ABE3400C160B9 /* MoviesListViewItemModel.swift */; }; 47 | 1FEED0DA20232B28000F4EAA /* MoviesListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FEED0D920232B28000F4EAA /* MoviesListViewModel.swift */; }; 48 | 1FEED0F02023576A000F4EAA /* Alertable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FEED0EF2023576A000F4EAA /* Alertable.swift */; }; 49 | 1FFFC831221B0041007D99D2 /* MoviesQueriesDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC827221B0041007D99D2 /* MoviesQueriesDataSource.swift */; }; 50 | 1FFFC832221B0041007D99D2 /* MoviesQueriesDataSourceInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC828221B0041007D99D2 /* MoviesQueriesDataSourceInterface.swift */; }; 51 | 1FFFC833221B0041007D99D2 /* PosterImagesDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC82B221B0041007D99D2 /* PosterImagesDataSource.swift */; }; 52 | 1FFFC834221B0041007D99D2 /* PosterImagesDataSourceInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC82C221B0041007D99D2 /* PosterImagesDataSourceInterface.swift */; }; 53 | 1FFFC835221B0041007D99D2 /* MoviesDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC82F221B0041007D99D2 /* MoviesDataSource.swift */; }; 54 | 1FFFC836221B0041007D99D2 /* MoviesDataSourceInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFFC830221B0041007D99D2 /* MoviesDataSourceInterface.swift */; }; 55 | FC2B713C2156C3D5002BD59E /* MoviesListViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FC2B71392156C3D4002BD59E /* MoviesListViewController.storyboard */; }; 56 | FC2B71422156C3EA002BD59E /* MoviesListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B71402156C3EA002BD59E /* MoviesListTableViewController.swift */; }; 57 | FC2B71432156C3EA002BD59E /* MoviesListItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B71412156C3EA002BD59E /* MoviesListItemCell.swift */; }; 58 | FC2B715C2156FF93002BD59E /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B715B2156FF93002BD59E /* AppAppearance.swift */; }; 59 | FC2B716221579844002BD59E /* MoviesListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC2B713B2156C3D4002BD59E /* MoviesListViewController.swift */; }; 60 | FC740818216555C500FE52A5 /* MoviesRecentQueriesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC740817216555C500FE52A5 /* MoviesRecentQueriesStorage.swift */; }; 61 | FC74081A2165574400FE52A5 /* MovieQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC7408192165574400FE52A5 /* MovieQuery.swift */; }; 62 | FC74081E2165606D00FE52A5 /* MoviesQueriesListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC74081D2165606D00FE52A5 /* MoviesQueriesListViewModel.swift */; }; 63 | /* End PBXBuildFile section */ 64 | 65 | /* Begin PBXContainerItemProxy section */ 66 | 1FA5339E201E1FDE00747E55 /* PBXContainerItemProxy */ = { 67 | isa = PBXContainerItemProxy; 68 | containerPortal = 1FA53381201E1FDE00747E55 /* Project object */; 69 | proxyType = 1; 70 | remoteGlobalIDString = 1FA53388201E1FDE00747E55; 71 | remoteInfo = CodeChallenge; 72 | }; 73 | /* End PBXContainerItemProxy section */ 74 | 75 | /* Begin PBXFileReference section */ 76 | 1F05A6CB2220A2CB001E2801 /* FetchMoviesUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMoviesUseCase.swift; sourceTree = ""; }; 77 | 1F1CD655222368CA00B0143C /* AppConfigurations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigurations.swift; sourceTree = ""; }; 78 | 1F1FC49B22E61EA800BCBA8D /* Network.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Network.swift; sourceTree = ""; }; 79 | 1F1FC49C22E61EA800BCBA8D /* DataTransfer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataTransfer.swift; sourceTree = ""; }; 80 | 1F1FC49D22E61EA800BCBA8D /* Endpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; 81 | 1F1FC49E22E61EA800BCBA8D /* NetworkConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkConfig.swift; sourceTree = ""; }; 82 | 1F326C3722B8163A00154226 /* FetchMovieOfferUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMovieOfferUseCase.swift; sourceTree = ""; }; 83 | 1F326C3922B8169800154226 /* MovieOffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieOffer.swift; sourceTree = ""; }; 84 | 1F326C4122B8243800154226 /* MVVMView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MVVMView.swift; sourceTree = ""; }; 85 | 1F326C4222B8243800154226 /* MVVMViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MVVMViewModel.swift; sourceTree = ""; }; 86 | 1F474F2422356B690092DB4B /* Cancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cancellable.swift; sourceTree = ""; }; 87 | 1F6EA7A922B6A6470075D7C0 /* APIEndpoints.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIEndpoints.swift; sourceTree = ""; }; 88 | 1F6EA7AB22B6A6470075D7C0 /* Movie+Mapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Movie+Mapping.swift"; sourceTree = ""; }; 89 | 1F77930E222C0DF2004E034C /* StoryboardInstantiable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryboardInstantiable.swift; sourceTree = ""; }; 90 | 1FA53389201E1FDE00747E55 /* ExampleMVVM.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExampleMVVM.app; sourceTree = BUILT_PRODUCTS_DIR; }; 91 | 1FA5338C201E1FDE00747E55 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 92 | 1FA53393201E1FDE00747E55 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 93 | 1FA53396201E1FDE00747E55 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 94 | 1FA53398201E1FDE00747E55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 95 | 1FA5339D201E1FDE00747E55 /* ExampleMVVMTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleMVVMTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 96 | 1FA533B1201EE2A500747E55 /* Movie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Movie.swift; sourceTree = ""; }; 97 | 1FA533D6201EF2B500747E55 /* AppDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDIContainer.swift; sourceTree = ""; }; 98 | 1FB2B47F222C10A900C9D095 /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; 99 | 1FB2B481222C10FC00C9D095 /* AppMainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMainCoordinator.swift; sourceTree = ""; }; 100 | 1FB2B483222C233D00C9D095 /* MoviesQueriesListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesQueriesListCoordinator.swift; sourceTree = ""; }; 101 | 1FB2B488222C5FB200C9D095 /* MoviesListViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesListViewCoordinator.swift; sourceTree = ""; }; 102 | 1FCE689D222C857B00CC3074 /* MoviesSceneDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesSceneDIContainer.swift; sourceTree = ""; }; 103 | 1FEE314B2217282000C160B9 /* MoviesQueriesItemCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesQueriesItemCell.swift; sourceTree = ""; }; 104 | 1FEE314C2217282000C160B9 /* MoviesQueriesTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesQueriesTableViewController.swift; sourceTree = ""; }; 105 | 1FEE314D2217282000C160B9 /* MoviesQueriesTableViewController.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = MoviesQueriesTableViewController.storyboard; sourceTree = ""; }; 106 | 1FEE31612218B17E00C160B9 /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = ""; }; 107 | 1FEE3163221ABE3400C160B9 /* MoviesListViewItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesListViewItemModel.swift; sourceTree = ""; }; 108 | 1FEED0D920232B28000F4EAA /* MoviesListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesListViewModel.swift; sourceTree = ""; }; 109 | 1FEED0EF2023576A000F4EAA /* Alertable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alertable.swift; sourceTree = ""; }; 110 | 1FFFC827221B0041007D99D2 /* MoviesQueriesDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesQueriesDataSource.swift; sourceTree = ""; }; 111 | 1FFFC828221B0041007D99D2 /* MoviesQueriesDataSourceInterface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesQueriesDataSourceInterface.swift; sourceTree = ""; }; 112 | 1FFFC82B221B0041007D99D2 /* PosterImagesDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterImagesDataSource.swift; sourceTree = ""; }; 113 | 1FFFC82C221B0041007D99D2 /* PosterImagesDataSourceInterface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PosterImagesDataSourceInterface.swift; sourceTree = ""; }; 114 | 1FFFC82F221B0041007D99D2 /* MoviesDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesDataSource.swift; sourceTree = ""; }; 115 | 1FFFC830221B0041007D99D2 /* MoviesDataSourceInterface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesDataSourceInterface.swift; sourceTree = ""; }; 116 | 1FFFC83B221B0299007D99D2 /* FetchMoviesUseCaseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FetchMoviesUseCaseTests.swift; path = ExampleMVVMTests/FetchMoviesUseCaseTests.swift; sourceTree = SOURCE_ROOT; }; 117 | 1FFFC83D221B02BA007D99D2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ExampleMVVMTests/Info.plist; sourceTree = SOURCE_ROOT; }; 118 | FC2B71392156C3D4002BD59E /* MoviesListViewController.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = MoviesListViewController.storyboard; sourceTree = ""; }; 119 | FC2B713B2156C3D4002BD59E /* MoviesListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesListViewController.swift; sourceTree = ""; }; 120 | FC2B71402156C3EA002BD59E /* MoviesListTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesListTableViewController.swift; sourceTree = ""; }; 121 | FC2B71412156C3EA002BD59E /* MoviesListItemCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MoviesListItemCell.swift; sourceTree = ""; }; 122 | FC2B715B2156FF93002BD59E /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = ""; }; 123 | FC740817216555C500FE52A5 /* MoviesRecentQueriesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesRecentQueriesStorage.swift; sourceTree = ""; }; 124 | FC7408192165574400FE52A5 /* MovieQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieQuery.swift; sourceTree = ""; }; 125 | FC74081D2165606D00FE52A5 /* MoviesQueriesListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesQueriesListViewModel.swift; sourceTree = ""; }; 126 | /* End PBXFileReference section */ 127 | 128 | /* Begin PBXFrameworksBuildPhase section */ 129 | 1FA53386201E1FDE00747E55 /* Frameworks */ = { 130 | isa = PBXFrameworksBuildPhase; 131 | buildActionMask = 2147483647; 132 | files = ( 133 | ); 134 | runOnlyForDeploymentPostprocessing = 0; 135 | }; 136 | 1FA5339A201E1FDE00747E55 /* Frameworks */ = { 137 | isa = PBXFrameworksBuildPhase; 138 | buildActionMask = 2147483647; 139 | files = ( 140 | ); 141 | runOnlyForDeploymentPostprocessing = 0; 142 | }; 143 | /* End PBXFrameworksBuildPhase section */ 144 | 145 | /* Begin PBXGroup section */ 146 | 1F1CD64A22230EF100B0143C /* Utils */ = { 147 | isa = PBXGroup; 148 | children = ( 149 | 1F326C4022B8243800154226 /* MVVM */, 150 | 1F326C3F22B823DB00154226 /* Protocols */, 151 | 1FEE31612218B17E00C160B9 /* Observable.swift */, 152 | ); 153 | path = Utils; 154 | sourceTree = ""; 155 | }; 156 | 1F1FC49A22E61EA800BCBA8D /* Network */ = { 157 | isa = PBXGroup; 158 | children = ( 159 | 1F1FC49B22E61EA800BCBA8D /* Network.swift */, 160 | 1F1FC49C22E61EA800BCBA8D /* DataTransfer.swift */, 161 | 1F1FC49D22E61EA800BCBA8D /* Endpoint.swift */, 162 | 1F1FC49E22E61EA800BCBA8D /* NetworkConfig.swift */, 163 | ); 164 | path = Network; 165 | sourceTree = ""; 166 | }; 167 | 1F326C3F22B823DB00154226 /* Protocols */ = { 168 | isa = PBXGroup; 169 | children = ( 170 | 1FEED0EF2023576A000F4EAA /* Alertable.swift */, 171 | 1F77930E222C0DF2004E034C /* StoryboardInstantiable.swift */, 172 | 1FB2B47F222C10A900C9D095 /* Coordinator.swift */, 173 | ); 174 | path = Protocols; 175 | sourceTree = ""; 176 | }; 177 | 1F326C4022B8243800154226 /* MVVM */ = { 178 | isa = PBXGroup; 179 | children = ( 180 | 1F326C4122B8243800154226 /* MVVMView.swift */, 181 | 1F326C4222B8243800154226 /* MVVMViewModel.swift */, 182 | ); 183 | path = MVVM; 184 | sourceTree = ""; 185 | }; 186 | 1F326C4522B824BB00154226 /* ViewModel */ = { 187 | isa = PBXGroup; 188 | children = ( 189 | 1FEED0D920232B28000F4EAA /* MoviesListViewModel.swift */, 190 | 1FEE3163221ABE3400C160B9 /* MoviesListViewItemModel.swift */, 191 | ); 192 | path = ViewModel; 193 | sourceTree = ""; 194 | }; 195 | 1F326C4622B824CA00154226 /* Coordinator */ = { 196 | isa = PBXGroup; 197 | children = ( 198 | 1FB2B488222C5FB200C9D095 /* MoviesListViewCoordinator.swift */, 199 | ); 200 | path = Coordinator; 201 | sourceTree = ""; 202 | }; 203 | 1F326C4722B8254B00154226 /* Coordinator */ = { 204 | isa = PBXGroup; 205 | children = ( 206 | 1FB2B483222C233D00C9D095 /* MoviesQueriesListCoordinator.swift */, 207 | ); 208 | path = Coordinator; 209 | sourceTree = ""; 210 | }; 211 | 1F326C4822B8255D00154226 /* ViewModel */ = { 212 | isa = PBXGroup; 213 | children = ( 214 | FC74081D2165606D00FE52A5 /* MoviesQueriesListViewModel.swift */, 215 | ); 216 | path = ViewModel; 217 | sourceTree = ""; 218 | }; 219 | 1F474F4C223668470092DB4B /* Repositories */ = { 220 | isa = PBXGroup; 221 | children = ( 222 | 1FFFC830221B0041007D99D2 /* MoviesDataSourceInterface.swift */, 223 | 1FFFC82C221B0041007D99D2 /* PosterImagesDataSourceInterface.swift */, 224 | 1FFFC828221B0041007D99D2 /* MoviesQueriesDataSourceInterface.swift */, 225 | ); 226 | path = Repositories; 227 | sourceTree = ""; 228 | }; 229 | 1F474F4D223668560092DB4B /* Services */ = { 230 | isa = PBXGroup; 231 | children = ( 232 | ); 233 | path = Services; 234 | sourceTree = ""; 235 | }; 236 | 1F5B9E20221D61290051B5F2 /* Data */ = { 237 | isa = PBXGroup; 238 | children = ( 239 | 1F6EA7A822B6A6470075D7C0 /* Network */, 240 | 1FFFC824221B0041007D99D2 /* Repositories */, 241 | FC7408122165557500FE52A5 /* PersistentStorages */, 242 | ); 243 | path = Data; 244 | sourceTree = ""; 245 | }; 246 | 1F6EA7A822B6A6470075D7C0 /* Network */ = { 247 | isa = PBXGroup; 248 | children = ( 249 | 1F6EA7A922B6A6470075D7C0 /* APIEndpoints.swift */, 250 | 1F6EA7AA22B6A6470075D7C0 /* DataMapping */, 251 | ); 252 | path = Network; 253 | sourceTree = ""; 254 | }; 255 | 1F6EA7AA22B6A6470075D7C0 /* DataMapping */ = { 256 | isa = PBXGroup; 257 | children = ( 258 | 1F6EA7AB22B6A6470075D7C0 /* Movie+Mapping.swift */, 259 | ); 260 | path = DataMapping; 261 | sourceTree = ""; 262 | }; 263 | 1FA53380201E1FDE00747E55 = { 264 | isa = PBXGroup; 265 | children = ( 266 | 1FA5338B201E1FDE00747E55 /* ExampleMVVM */, 267 | 1FA533A0201E1FDE00747E55 /* ExampleMVVMTests */, 268 | 1FA5338A201E1FDE00747E55 /* Products */, 269 | ); 270 | sourceTree = ""; 271 | }; 272 | 1FA5338A201E1FDE00747E55 /* Products */ = { 273 | isa = PBXGroup; 274 | children = ( 275 | 1FA53389201E1FDE00747E55 /* ExampleMVVM.app */, 276 | 1FA5339D201E1FDE00747E55 /* ExampleMVVMTests.xctest */, 277 | ); 278 | name = Products; 279 | sourceTree = ""; 280 | }; 281 | 1FA5338B201E1FDE00747E55 /* ExampleMVVM */ = { 282 | isa = PBXGroup; 283 | children = ( 284 | 1FA533D5201EF29600747E55 /* Application */, 285 | 1FA533D4201EF28800747E55 /* Presentation */, 286 | 1FA533D1201EF1EC00747E55 /* Domain */, 287 | 1F5B9E20221D61290051B5F2 /* Data */, 288 | 1FEED0D320231D93000F4EAA /* Infrastructure */, 289 | 1FEED0D520231F72000F4EAA /* Common */, 290 | 1FA533AD201E205000747E55 /* Resources */, 291 | ); 292 | path = ExampleMVVM; 293 | sourceTree = ""; 294 | }; 295 | 1FA533A0201E1FDE00747E55 /* ExampleMVVMTests */ = { 296 | isa = PBXGroup; 297 | children = ( 298 | 1FFFC83B221B0299007D99D2 /* FetchMoviesUseCaseTests.swift */, 299 | 1FFFC83D221B02BA007D99D2 /* Info.plist */, 300 | ); 301 | name = ExampleMVVMTests; 302 | path = ExampleMVVM; 303 | sourceTree = ""; 304 | }; 305 | 1FA533AD201E205000747E55 /* Resources */ = { 306 | isa = PBXGroup; 307 | children = ( 308 | 1FA53393201E1FDE00747E55 /* Assets.xcassets */, 309 | 1FA53398201E1FDE00747E55 /* Info.plist */, 310 | 1FA53395201E1FDE00747E55 /* LaunchScreen.storyboard */, 311 | ); 312 | path = Resources; 313 | sourceTree = ""; 314 | }; 315 | 1FA533D1201EF1EC00747E55 /* Domain */ = { 316 | isa = PBXGroup; 317 | children = ( 318 | 1FA533D2201EF21000747E55 /* Entities */, 319 | 1FA533D8201EF2F100747E55 /* UseCases */, 320 | 1FF3242F2229AC780004E6CC /* Interfaces */, 321 | ); 322 | path = Domain; 323 | sourceTree = ""; 324 | }; 325 | 1FA533D2201EF21000747E55 /* Entities */ = { 326 | isa = PBXGroup; 327 | children = ( 328 | 1FA533B1201EE2A500747E55 /* Movie.swift */, 329 | FC7408192165574400FE52A5 /* MovieQuery.swift */, 330 | 1F326C3922B8169800154226 /* MovieOffer.swift */, 331 | ); 332 | path = Entities; 333 | sourceTree = ""; 334 | }; 335 | 1FA533D4201EF28800747E55 /* Presentation */ = { 336 | isa = PBXGroup; 337 | children = ( 338 | 1FCE689C222C855D00CC3074 /* MoviesScene */, 339 | 1F1CD64A22230EF100B0143C /* Utils */, 340 | ); 341 | path = Presentation; 342 | sourceTree = ""; 343 | }; 344 | 1FA533D5201EF29600747E55 /* Application */ = { 345 | isa = PBXGroup; 346 | children = ( 347 | 1FA5338C201E1FDE00747E55 /* AppDelegate.swift */, 348 | 1FA533D6201EF2B500747E55 /* AppDIContainer.swift */, 349 | FC2B715B2156FF93002BD59E /* AppAppearance.swift */, 350 | 1F1CD655222368CA00B0143C /* AppConfigurations.swift */, 351 | 1FB2B481222C10FC00C9D095 /* AppMainCoordinator.swift */, 352 | ); 353 | path = Application; 354 | sourceTree = ""; 355 | }; 356 | 1FA533D8201EF2F100747E55 /* UseCases */ = { 357 | isa = PBXGroup; 358 | children = ( 359 | 1F05A6CB2220A2CB001E2801 /* FetchMoviesUseCase.swift */, 360 | 1F326C3722B8163A00154226 /* FetchMovieOfferUseCase.swift */, 361 | ); 362 | path = UseCases; 363 | sourceTree = ""; 364 | }; 365 | 1FCE689C222C855D00CC3074 /* MoviesScene */ = { 366 | isa = PBXGroup; 367 | children = ( 368 | 1FCE689D222C857B00CC3074 /* MoviesSceneDIContainer.swift */, 369 | 1FEED0BF2021DA58000F4EAA /* MoviesList */, 370 | 1FEE313F2217282000C160B9 /* MoviesQueriesList */, 371 | ); 372 | path = MoviesScene; 373 | sourceTree = ""; 374 | }; 375 | 1FEE313F2217282000C160B9 /* MoviesQueriesList */ = { 376 | isa = PBXGroup; 377 | children = ( 378 | 1F326C4722B8254B00154226 /* Coordinator */, 379 | 1F326C4822B8255D00154226 /* ViewModel */, 380 | 1FEE31442217282000C160B9 /* View */, 381 | ); 382 | path = MoviesQueriesList; 383 | sourceTree = ""; 384 | }; 385 | 1FEE31442217282000C160B9 /* View */ = { 386 | isa = PBXGroup; 387 | children = ( 388 | 1FEE314C2217282000C160B9 /* MoviesQueriesTableViewController.swift */, 389 | 1FEE314D2217282000C160B9 /* MoviesQueriesTableViewController.storyboard */, 390 | 1FEE314A2217282000C160B9 /* Cells */, 391 | ); 392 | path = View; 393 | sourceTree = ""; 394 | }; 395 | 1FEE314A2217282000C160B9 /* Cells */ = { 396 | isa = PBXGroup; 397 | children = ( 398 | 1FEE314B2217282000C160B9 /* MoviesQueriesItemCell.swift */, 399 | ); 400 | path = Cells; 401 | sourceTree = ""; 402 | }; 403 | 1FEED0BF2021DA58000F4EAA /* MoviesList */ = { 404 | isa = PBXGroup; 405 | children = ( 406 | 1F326C4622B824CA00154226 /* Coordinator */, 407 | 1F326C4522B824BB00154226 /* ViewModel */, 408 | FC2B71382156C3BD002BD59E /* View */, 409 | ); 410 | path = MoviesList; 411 | sourceTree = ""; 412 | }; 413 | 1FEED0D320231D93000F4EAA /* Infrastructure */ = { 414 | isa = PBXGroup; 415 | children = ( 416 | 1F1FC49A22E61EA800BCBA8D /* Network */, 417 | ); 418 | path = Infrastructure; 419 | sourceTree = ""; 420 | }; 421 | 1FEED0D520231F72000F4EAA /* Common */ = { 422 | isa = PBXGroup; 423 | children = ( 424 | 1F474F2422356B690092DB4B /* Cancellable.swift */, 425 | ); 426 | path = Common; 427 | sourceTree = ""; 428 | }; 429 | 1FF3242F2229AC780004E6CC /* Interfaces */ = { 430 | isa = PBXGroup; 431 | children = ( 432 | 1F474F4C223668470092DB4B /* Repositories */, 433 | 1F474F4D223668560092DB4B /* Services */, 434 | ); 435 | path = Interfaces; 436 | sourceTree = ""; 437 | }; 438 | 1FFFC824221B0041007D99D2 /* Repositories */ = { 439 | isa = PBXGroup; 440 | children = ( 441 | 1FFFC827221B0041007D99D2 /* MoviesQueriesDataSource.swift */, 442 | 1FFFC82F221B0041007D99D2 /* MoviesDataSource.swift */, 443 | 1FFFC82B221B0041007D99D2 /* PosterImagesDataSource.swift */, 444 | ); 445 | path = Repositories; 446 | sourceTree = ""; 447 | }; 448 | FC2B71382156C3BD002BD59E /* View */ = { 449 | isa = PBXGroup; 450 | children = ( 451 | FC2B713B2156C3D4002BD59E /* MoviesListViewController.swift */, 452 | FC2B71392156C3D4002BD59E /* MoviesListViewController.storyboard */, 453 | FC2B713F2156C3DF002BD59E /* MoviesListTableView */, 454 | ); 455 | path = View; 456 | sourceTree = ""; 457 | }; 458 | FC2B713F2156C3DF002BD59E /* MoviesListTableView */ = { 459 | isa = PBXGroup; 460 | children = ( 461 | FC2B71402156C3EA002BD59E /* MoviesListTableViewController.swift */, 462 | FC7FDEEE2157C41500D98241 /* Cells */, 463 | ); 464 | path = MoviesListTableView; 465 | sourceTree = ""; 466 | }; 467 | FC7408122165557500FE52A5 /* PersistentStorages */ = { 468 | isa = PBXGroup; 469 | children = ( 470 | FC740817216555C500FE52A5 /* MoviesRecentQueriesStorage.swift */, 471 | ); 472 | path = PersistentStorages; 473 | sourceTree = ""; 474 | }; 475 | FC7FDEEE2157C41500D98241 /* Cells */ = { 476 | isa = PBXGroup; 477 | children = ( 478 | FC2B71412156C3EA002BD59E /* MoviesListItemCell.swift */, 479 | ); 480 | path = Cells; 481 | sourceTree = ""; 482 | }; 483 | /* End PBXGroup section */ 484 | 485 | /* Begin PBXNativeTarget section */ 486 | 1FA53388201E1FDE00747E55 /* ExampleMVVM */ = { 487 | isa = PBXNativeTarget; 488 | buildConfigurationList = 1FA533A6201E1FDE00747E55 /* Build configuration list for PBXNativeTarget "ExampleMVVM" */; 489 | buildPhases = ( 490 | 1FA53385201E1FDE00747E55 /* Sources */, 491 | 1FA53386201E1FDE00747E55 /* Frameworks */, 492 | 1FA53387201E1FDE00747E55 /* Resources */, 493 | ); 494 | buildRules = ( 495 | ); 496 | dependencies = ( 497 | ); 498 | name = ExampleMVVM; 499 | productName = CodeChallenge; 500 | productReference = 1FA53389201E1FDE00747E55 /* ExampleMVVM.app */; 501 | productType = "com.apple.product-type.application"; 502 | }; 503 | 1FA5339C201E1FDE00747E55 /* ExampleMVVMTests */ = { 504 | isa = PBXNativeTarget; 505 | buildConfigurationList = 1FA533A9201E1FDE00747E55 /* Build configuration list for PBXNativeTarget "ExampleMVVMTests" */; 506 | buildPhases = ( 507 | 1FA53399201E1FDE00747E55 /* Sources */, 508 | 1FA5339A201E1FDE00747E55 /* Frameworks */, 509 | 1FA5339B201E1FDE00747E55 /* Resources */, 510 | ); 511 | buildRules = ( 512 | ); 513 | dependencies = ( 514 | 1FA5339F201E1FDE00747E55 /* PBXTargetDependency */, 515 | ); 516 | name = ExampleMVVMTests; 517 | productName = CodeChallengeTests; 518 | productReference = 1FA5339D201E1FDE00747E55 /* ExampleMVVMTests.xctest */; 519 | productType = "com.apple.product-type.bundle.unit-test"; 520 | }; 521 | /* End PBXNativeTarget section */ 522 | 523 | /* Begin PBXProject section */ 524 | 1FA53381201E1FDE00747E55 /* Project object */ = { 525 | isa = PBXProject; 526 | attributes = { 527 | LastSwiftUpdateCheck = 0920; 528 | LastUpgradeCheck = 0940; 529 | TargetAttributes = { 530 | 1FA53388201E1FDE00747E55 = { 531 | CreatedOnToolsVersion = 9.2; 532 | LastSwiftMigration = 1020; 533 | ProvisioningStyle = Manual; 534 | }; 535 | 1FA5339C201E1FDE00747E55 = { 536 | CreatedOnToolsVersion = 9.2; 537 | LastSwiftMigration = 1020; 538 | ProvisioningStyle = Automatic; 539 | }; 540 | }; 541 | }; 542 | buildConfigurationList = 1FA53384201E1FDE00747E55 /* Build configuration list for PBXProject "ExampleMVVM" */; 543 | compatibilityVersion = "Xcode 8.0"; 544 | developmentRegion = en; 545 | hasScannedForEncodings = 0; 546 | knownRegions = ( 547 | en, 548 | Base, 549 | ); 550 | mainGroup = 1FA53380201E1FDE00747E55; 551 | productRefGroup = 1FA5338A201E1FDE00747E55 /* Products */; 552 | projectDirPath = ""; 553 | projectRoot = ""; 554 | targets = ( 555 | 1FA53388201E1FDE00747E55 /* ExampleMVVM */, 556 | 1FA5339C201E1FDE00747E55 /* ExampleMVVMTests */, 557 | ); 558 | }; 559 | /* End PBXProject section */ 560 | 561 | /* Begin PBXResourcesBuildPhase section */ 562 | 1FA53387201E1FDE00747E55 /* Resources */ = { 563 | isa = PBXResourcesBuildPhase; 564 | buildActionMask = 2147483647; 565 | files = ( 566 | 1FEE315A2217282000C160B9 /* MoviesQueriesTableViewController.storyboard in Resources */, 567 | 1FA53397201E1FDE00747E55 /* LaunchScreen.storyboard in Resources */, 568 | 1FA53394201E1FDE00747E55 /* Assets.xcassets in Resources */, 569 | FC2B713C2156C3D5002BD59E /* MoviesListViewController.storyboard in Resources */, 570 | ); 571 | runOnlyForDeploymentPostprocessing = 0; 572 | }; 573 | 1FA5339B201E1FDE00747E55 /* Resources */ = { 574 | isa = PBXResourcesBuildPhase; 575 | buildActionMask = 2147483647; 576 | files = ( 577 | ); 578 | runOnlyForDeploymentPostprocessing = 0; 579 | }; 580 | /* End PBXResourcesBuildPhase section */ 581 | 582 | /* Begin PBXSourcesBuildPhase section */ 583 | 1FA53385201E1FDE00747E55 /* Sources */ = { 584 | isa = PBXSourcesBuildPhase; 585 | buildActionMask = 2147483647; 586 | files = ( 587 | 1FCE689E222C857B00CC3074 /* MoviesSceneDIContainer.swift in Sources */, 588 | 1FEE3164221ABE3400C160B9 /* MoviesListViewItemModel.swift in Sources */, 589 | 1FEED0F02023576A000F4EAA /* Alertable.swift in Sources */, 590 | 1F6EA7AD22B6A6470075D7C0 /* Movie+Mapping.swift in Sources */, 591 | FC2B715C2156FF93002BD59E /* AppAppearance.swift in Sources */, 592 | FC2B716221579844002BD59E /* MoviesListViewController.swift in Sources */, 593 | FC74081A2165574400FE52A5 /* MovieQuery.swift in Sources */, 594 | 1FFFC835221B0041007D99D2 /* MoviesDataSource.swift in Sources */, 595 | 1F77930F222C0DF2004E034C /* StoryboardInstantiable.swift in Sources */, 596 | FC74081E2165606D00FE52A5 /* MoviesQueriesListViewModel.swift in Sources */, 597 | 1F6EA7AC22B6A6470075D7C0 /* APIEndpoints.swift in Sources */, 598 | 1F326C3A22B8169800154226 /* MovieOffer.swift in Sources */, 599 | 1F1FC49F22E61EA800BCBA8D /* Network.swift in Sources */, 600 | 1FEE31622218B17E00C160B9 /* Observable.swift in Sources */, 601 | 1F474F2522356B690092DB4B /* Cancellable.swift in Sources */, 602 | 1FFFC836221B0041007D99D2 /* MoviesDataSourceInterface.swift in Sources */, 603 | 1F326C3822B8163A00154226 /* FetchMovieOfferUseCase.swift in Sources */, 604 | 1FEE31582217282000C160B9 /* MoviesQueriesItemCell.swift in Sources */, 605 | 1F05A6CC2220A2CB001E2801 /* FetchMoviesUseCase.swift in Sources */, 606 | 1FA533D7201EF2B500747E55 /* AppDIContainer.swift in Sources */, 607 | 1FB2B484222C233D00C9D095 /* MoviesQueriesListCoordinator.swift in Sources */, 608 | 1FFFC831221B0041007D99D2 /* MoviesQueriesDataSource.swift in Sources */, 609 | FC2B71422156C3EA002BD59E /* MoviesListTableViewController.swift in Sources */, 610 | 1FFFC832221B0041007D99D2 /* MoviesQueriesDataSourceInterface.swift in Sources */, 611 | 1F1FC4A022E61EA800BCBA8D /* DataTransfer.swift in Sources */, 612 | 1FEED0DA20232B28000F4EAA /* MoviesListViewModel.swift in Sources */, 613 | 1F1FC4A222E61EA800BCBA8D /* NetworkConfig.swift in Sources */, 614 | 1F326C4322B8243800154226 /* MVVMView.swift in Sources */, 615 | 1F1CD656222368CA00B0143C /* AppConfigurations.swift in Sources */, 616 | 1FFFC833221B0041007D99D2 /* PosterImagesDataSource.swift in Sources */, 617 | FC740818216555C500FE52A5 /* MoviesRecentQueriesStorage.swift in Sources */, 618 | 1FB2B480222C10A900C9D095 /* Coordinator.swift in Sources */, 619 | 1FB2B482222C10FC00C9D095 /* AppMainCoordinator.swift in Sources */, 620 | 1F326C4422B8243800154226 /* MVVMViewModel.swift in Sources */, 621 | 1FEE31592217282000C160B9 /* MoviesQueriesTableViewController.swift in Sources */, 622 | 1F1FC4A122E61EA800BCBA8D /* Endpoint.swift in Sources */, 623 | 1FFFC834221B0041007D99D2 /* PosterImagesDataSourceInterface.swift in Sources */, 624 | 1FA5338D201E1FDE00747E55 /* AppDelegate.swift in Sources */, 625 | FC2B71432156C3EA002BD59E /* MoviesListItemCell.swift in Sources */, 626 | 1FB2B489222C5FB200C9D095 /* MoviesListViewCoordinator.swift in Sources */, 627 | 1FA533B2201EE2A500747E55 /* Movie.swift in Sources */, 628 | ); 629 | runOnlyForDeploymentPostprocessing = 0; 630 | }; 631 | 1FA53399201E1FDE00747E55 /* Sources */ = { 632 | isa = PBXSourcesBuildPhase; 633 | buildActionMask = 2147483647; 634 | files = ( 635 | 1F474F3522356CEC0092DB4B /* FetchMoviesUseCase.swift in Sources */, 636 | 1F474F3222356CEC0092DB4B /* Movie.swift in Sources */, 637 | 1F474F3A22356CEC0092DB4B /* Cancellable.swift in Sources */, 638 | 1F474F3122356CDD0092DB4B /* FetchMoviesUseCaseTests.swift in Sources */, 639 | 1F474F3322356CEC0092DB4B /* MovieQuery.swift in Sources */, 640 | 1F474F3722356CEC0092DB4B /* PosterImagesDataSourceInterface.swift in Sources */, 641 | 1F474F3822356CEC0092DB4B /* MoviesQueriesDataSourceInterface.swift in Sources */, 642 | 1F474F3622356CEC0092DB4B /* MoviesDataSourceInterface.swift in Sources */, 643 | ); 644 | runOnlyForDeploymentPostprocessing = 0; 645 | }; 646 | /* End PBXSourcesBuildPhase section */ 647 | 648 | /* Begin PBXTargetDependency section */ 649 | 1FA5339F201E1FDE00747E55 /* PBXTargetDependency */ = { 650 | isa = PBXTargetDependency; 651 | target = 1FA53388201E1FDE00747E55 /* ExampleMVVM */; 652 | targetProxy = 1FA5339E201E1FDE00747E55 /* PBXContainerItemProxy */; 653 | }; 654 | /* End PBXTargetDependency section */ 655 | 656 | /* Begin PBXVariantGroup section */ 657 | 1FA53395201E1FDE00747E55 /* LaunchScreen.storyboard */ = { 658 | isa = PBXVariantGroup; 659 | children = ( 660 | 1FA53396201E1FDE00747E55 /* Base */, 661 | ); 662 | name = LaunchScreen.storyboard; 663 | sourceTree = ""; 664 | }; 665 | /* End PBXVariantGroup section */ 666 | 667 | /* Begin XCBuildConfiguration section */ 668 | 1FA533A4201E1FDE00747E55 /* Debug */ = { 669 | isa = XCBuildConfiguration; 670 | buildSettings = { 671 | ALWAYS_SEARCH_USER_PATHS = NO; 672 | CLANG_ANALYZER_NONNULL = YES; 673 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 674 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 675 | CLANG_CXX_LIBRARY = "libc++"; 676 | CLANG_ENABLE_MODULES = YES; 677 | CLANG_ENABLE_OBJC_ARC = YES; 678 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 679 | CLANG_WARN_BOOL_CONVERSION = YES; 680 | CLANG_WARN_COMMA = YES; 681 | CLANG_WARN_CONSTANT_CONVERSION = YES; 682 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 683 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 684 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 685 | CLANG_WARN_EMPTY_BODY = YES; 686 | CLANG_WARN_ENUM_CONVERSION = YES; 687 | CLANG_WARN_INFINITE_RECURSION = YES; 688 | CLANG_WARN_INT_CONVERSION = YES; 689 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 690 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 691 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 692 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 693 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 694 | CLANG_WARN_STRICT_PROTOTYPES = YES; 695 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 696 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 697 | CLANG_WARN_UNREACHABLE_CODE = YES; 698 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 699 | CODE_SIGN_IDENTITY = "iPhone Developer"; 700 | COPY_PHASE_STRIP = NO; 701 | DEBUG_INFORMATION_FORMAT = dwarf; 702 | ENABLE_STRICT_OBJC_MSGSEND = YES; 703 | ENABLE_TESTABILITY = YES; 704 | GCC_C_LANGUAGE_STANDARD = gnu11; 705 | GCC_DYNAMIC_NO_PIC = NO; 706 | GCC_NO_COMMON_BLOCKS = YES; 707 | GCC_OPTIMIZATION_LEVEL = 0; 708 | GCC_PREPROCESSOR_DEFINITIONS = ( 709 | "DEBUG=1", 710 | "$(inherited)", 711 | ); 712 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 713 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 714 | GCC_WARN_UNDECLARED_SELECTOR = YES; 715 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 716 | GCC_WARN_UNUSED_FUNCTION = YES; 717 | GCC_WARN_UNUSED_VARIABLE = YES; 718 | IPHONEOS_DEPLOYMENT_TARGET = 11.2; 719 | MTL_ENABLE_DEBUG_INFO = YES; 720 | ONLY_ACTIVE_ARCH = YES; 721 | SDKROOT = iphoneos; 722 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 723 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 724 | }; 725 | name = Debug; 726 | }; 727 | 1FA533A5201E1FDE00747E55 /* Release */ = { 728 | isa = XCBuildConfiguration; 729 | buildSettings = { 730 | ALWAYS_SEARCH_USER_PATHS = NO; 731 | CLANG_ANALYZER_NONNULL = YES; 732 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 733 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 734 | CLANG_CXX_LIBRARY = "libc++"; 735 | CLANG_ENABLE_MODULES = YES; 736 | CLANG_ENABLE_OBJC_ARC = YES; 737 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 738 | CLANG_WARN_BOOL_CONVERSION = YES; 739 | CLANG_WARN_COMMA = YES; 740 | CLANG_WARN_CONSTANT_CONVERSION = YES; 741 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 742 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 743 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 744 | CLANG_WARN_EMPTY_BODY = YES; 745 | CLANG_WARN_ENUM_CONVERSION = YES; 746 | CLANG_WARN_INFINITE_RECURSION = YES; 747 | CLANG_WARN_INT_CONVERSION = YES; 748 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 749 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 750 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 751 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 752 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 753 | CLANG_WARN_STRICT_PROTOTYPES = YES; 754 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 755 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 756 | CLANG_WARN_UNREACHABLE_CODE = YES; 757 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 758 | CODE_SIGN_IDENTITY = "iPhone Developer"; 759 | COPY_PHASE_STRIP = NO; 760 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 761 | ENABLE_NS_ASSERTIONS = NO; 762 | ENABLE_STRICT_OBJC_MSGSEND = YES; 763 | GCC_C_LANGUAGE_STANDARD = gnu11; 764 | GCC_NO_COMMON_BLOCKS = YES; 765 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 766 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 767 | GCC_WARN_UNDECLARED_SELECTOR = YES; 768 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 769 | GCC_WARN_UNUSED_FUNCTION = YES; 770 | GCC_WARN_UNUSED_VARIABLE = YES; 771 | IPHONEOS_DEPLOYMENT_TARGET = 11.2; 772 | MTL_ENABLE_DEBUG_INFO = NO; 773 | SDKROOT = iphoneos; 774 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 775 | VALIDATE_PRODUCT = YES; 776 | }; 777 | name = Release; 778 | }; 779 | 1FA533A7201E1FDE00747E55 /* Debug */ = { 780 | isa = XCBuildConfiguration; 781 | buildSettings = { 782 | API_BASE_URL = "http://api.themoviedb.org"; 783 | API_KEY = 2696829a81b1b5827d515ff121700838; 784 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 785 | CODE_SIGN_STYLE = Manual; 786 | DEVELOPMENT_TEAM = ""; 787 | IMAGE_BASE_URL = "http://image.tmdb.org"; 788 | INFOPLIST_FILE = "$(SRCROOT)/ExampleMVVM/Resources/Info.plist"; 789 | IPHONEOS_DEPLOYMENT_TARGET = 12.4; 790 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 791 | PRODUCT_BUNDLE_IDENTIFIER = "Oleh-Kudinov.ExampleMVVM"; 792 | PRODUCT_NAME = "$(TARGET_NAME)"; 793 | PROVISIONING_PROFILE_SPECIFIER = ""; 794 | SWIFT_VERSION = 5.0; 795 | TARGETED_DEVICE_FAMILY = "1,2"; 796 | }; 797 | name = Debug; 798 | }; 799 | 1FA533A8201E1FDE00747E55 /* Release */ = { 800 | isa = XCBuildConfiguration; 801 | buildSettings = { 802 | API_BASE_URL = "http://api.themoviedb.org"; 803 | API_KEY = 2696829a81b1b5827d515ff121700838; 804 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 805 | CODE_SIGN_STYLE = Manual; 806 | DEVELOPMENT_TEAM = ""; 807 | IMAGE_BASE_URL = "http://image.tmdb.org"; 808 | INFOPLIST_FILE = "$(SRCROOT)/ExampleMVVM/Resources/Info.plist"; 809 | IPHONEOS_DEPLOYMENT_TARGET = 12.4; 810 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 811 | PRODUCT_BUNDLE_IDENTIFIER = "Oleh-Kudinov.ExampleMVVM"; 812 | PRODUCT_NAME = "$(TARGET_NAME)"; 813 | PROVISIONING_PROFILE_SPECIFIER = ""; 814 | SWIFT_VERSION = 5.0; 815 | TARGETED_DEVICE_FAMILY = "1,2"; 816 | }; 817 | name = Release; 818 | }; 819 | 1FA533AA201E1FDE00747E55 /* Debug */ = { 820 | isa = XCBuildConfiguration; 821 | buildSettings = { 822 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 823 | CLANG_ENABLE_MODULES = YES; 824 | CODE_SIGN_STYLE = Automatic; 825 | DEVELOPMENT_TEAM = 8K9VMBS5JX; 826 | INFOPLIST_FILE = ExampleMVVMTests/Info.plist; 827 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 828 | OTHER_SWIFT_FLAGS = ""; 829 | PRODUCT_BUNDLE_IDENTIFIER = "Oleh-Kudinov.ExampleMVVMTests"; 830 | PRODUCT_NAME = "$(TARGET_NAME)"; 831 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 832 | SWIFT_VERSION = 5.0; 833 | TARGETED_DEVICE_FAMILY = "1,2"; 834 | }; 835 | name = Debug; 836 | }; 837 | 1FA533AB201E1FDE00747E55 /* Release */ = { 838 | isa = XCBuildConfiguration; 839 | buildSettings = { 840 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 841 | CLANG_ENABLE_MODULES = YES; 842 | CODE_SIGN_STYLE = Automatic; 843 | DEVELOPMENT_TEAM = 8K9VMBS5JX; 844 | INFOPLIST_FILE = ExampleMVVMTests/Info.plist; 845 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 846 | OTHER_SWIFT_FLAGS = ""; 847 | PRODUCT_BUNDLE_IDENTIFIER = "Oleh-Kudinov.ExampleMVVMTests"; 848 | PRODUCT_NAME = "$(TARGET_NAME)"; 849 | SWIFT_VERSION = 5.0; 850 | TARGETED_DEVICE_FAMILY = "1,2"; 851 | }; 852 | name = Release; 853 | }; 854 | /* End XCBuildConfiguration section */ 855 | 856 | /* Begin XCConfigurationList section */ 857 | 1FA53384201E1FDE00747E55 /* Build configuration list for PBXProject "ExampleMVVM" */ = { 858 | isa = XCConfigurationList; 859 | buildConfigurations = ( 860 | 1FA533A4201E1FDE00747E55 /* Debug */, 861 | 1FA533A5201E1FDE00747E55 /* Release */, 862 | ); 863 | defaultConfigurationIsVisible = 0; 864 | defaultConfigurationName = Release; 865 | }; 866 | 1FA533A6201E1FDE00747E55 /* Build configuration list for PBXNativeTarget "ExampleMVVM" */ = { 867 | isa = XCConfigurationList; 868 | buildConfigurations = ( 869 | 1FA533A7201E1FDE00747E55 /* Debug */, 870 | 1FA533A8201E1FDE00747E55 /* Release */, 871 | ); 872 | defaultConfigurationIsVisible = 0; 873 | defaultConfigurationName = Release; 874 | }; 875 | 1FA533A9201E1FDE00747E55 /* Build configuration list for PBXNativeTarget "ExampleMVVMTests" */ = { 876 | isa = XCConfigurationList; 877 | buildConfigurations = ( 878 | 1FA533AA201E1FDE00747E55 /* Debug */, 879 | 1FA533AB201E1FDE00747E55 /* Release */, 880 | ); 881 | defaultConfigurationIsVisible = 0; 882 | defaultConfigurationName = Release; 883 | }; 884 | /* End XCConfigurationList section */ 885 | }; 886 | rootObject = 1FA53381201E1FDE00747E55 /* Project object */; 887 | } 888 | -------------------------------------------------------------------------------- /ExampleMVVM.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ExampleMVVM.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ExampleMVVM.xcodeproj/project.xcworkspace/xcuserdata/oleh.kudinov.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /ExampleMVVM.xcodeproj/project.xcworkspace/xcuserdata/oleh.kudinov.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Example-MVVM-C/1aaa3b2925683a033a609d6e21fd73bc9c05091d/ExampleMVVM.xcodeproj/project.xcworkspace/xcuserdata/oleh.kudinov.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /ExampleMVVM.xcodeproj/xcshareddata/xcschemes/ExampleMVVM.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 76 | 77 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /ExampleMVVM.xcodeproj/xcuserdata/oleh.kudinov.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /ExampleMVVM.xcodeproj/xcuserdata/oleh.kudinov.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | ExampleMVVM.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | 1FA53388201E1FDE00747E55 16 | 17 | primary 18 | 19 | 20 | 1FA5339C201E1FDE00747E55 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/AppAppearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppAppearance.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 23.09.18. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | final class AppAppearance { 12 | 13 | static func setupAppearance() { 14 | if #available(iOS 15, *) { 15 | let appearance = UINavigationBarAppearance() 16 | appearance.configureWithOpaqueBackground() 17 | appearance.titleTextAttributes = [.foregroundColor: UIColor.white] 18 | appearance.backgroundColor = UIColor(red: 37/255.0, green: 37/255.0, blue: 37.0/255.0, alpha: 1.0) 19 | UINavigationBar.appearance().standardAppearance = appearance 20 | UINavigationBar.appearance().scrollEdgeAppearance = appearance 21 | } else { 22 | UINavigationBar.appearance().barTintColor = .black 23 | UINavigationBar.appearance().tintColor = .white 24 | UINavigationBar.appearance().titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white] 25 | } 26 | } 27 | } 28 | 29 | extension UINavigationController 30 | { 31 | @objc override open var preferredStatusBarStyle: UIStatusBarStyle { 32 | return .lightContent 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/AppConfigurations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppConfigurations.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 25.02.19. 6 | // 7 | 8 | import Foundation 9 | 10 | class AppConfigurations { 11 | lazy var apiKey = Bundle.main.object(forInfoDictionaryKey: "ApiKey") as! String 12 | lazy var apiBaseURL = Bundle.main.object(forInfoDictionaryKey: "ApiBaseURL") as! String 13 | lazy var imagesBaseURL = Bundle.main.object(forInfoDictionaryKey: "ImageBaseURL") as! String 14 | } 15 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/AppDIContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DIContainer.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | // Dependencies that have to exist during all app life cicle 12 | class AppDIContainer { 13 | 14 | lazy var appConfigurations = AppConfigurations() 15 | 16 | // MARK: - Network 17 | lazy var apiDataTransferService: DataTransferInterface = { 18 | 19 | let config = ApiDataNetworkConfig(baseURL: URL(string: appConfigurations.apiBaseURL)!, 20 | apiKey: appConfigurations.apiKey) 21 | let apiDataNetwork = NetworkService(session: URLSession.shared, 22 | config: config) 23 | return DataTransferService(with: apiDataNetwork) 24 | }() 25 | lazy var imageDataTransferService: DataTransferInterface = { 26 | let config = ApiDataNetworkConfig(baseURL: URL(string: appConfigurations.imagesBaseURL)!) 27 | let carrierLogosDataNetwork = NetworkService(session: URLSession.shared, 28 | config: config) 29 | return DataTransferService(with: carrierLogosDataNetwork) 30 | }() 31 | } 32 | 33 | extension AppDIContainer: MoviesSceneDIContainerDependencies {} 34 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import UIKit 9 | 10 | @UIApplicationMain 11 | class AppDelegate: UIResponder, UIApplicationDelegate { 12 | 13 | let appDIContainer = AppDIContainer() 14 | var window: UIWindow? 15 | private var mainCoordinator: AppMainCoordinator? 16 | 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 18 | 19 | AppAppearance.setupAppearance() 20 | 21 | window = UIWindow(frame: UIScreen.main.bounds) 22 | 23 | let navigationController = UINavigationController() 24 | window?.rootViewController = navigationController 25 | mainCoordinator = AppMainCoordinator(navigationController: navigationController, 26 | appDIContainer: appDIContainer) 27 | mainCoordinator?.start() 28 | window?.makeKeyAndVisible() 29 | 30 | return true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/AppMainCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppCoordinator.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.03.19. 6 | // 7 | 8 | import UIKit 9 | 10 | class AppMainCoordinator: Coordinator { 11 | 12 | var childCoordinators: [Coordinator] = [] 13 | 14 | var navigationController: UINavigationController 15 | private let appDIContainer: AppDIContainer 16 | 17 | init(navigationController: UINavigationController, 18 | appDIContainer: AppDIContainer) { 19 | self.navigationController = navigationController 20 | self.appDIContainer = appDIContainer 21 | } 22 | 23 | func start() { 24 | 25 | let coordinator = MoviesListViewCoordinator(navigationController: navigationController, 26 | dependencies: MoviesSceneDIContainer(dependencies: appDIContainer)) 27 | coordinator.start() 28 | childCoordinators.append(coordinator) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ExampleMVVM/Common/Cancellable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cancellable.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 10.03.19. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol Cancellable { 11 | func cancel() 12 | } 13 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Network/APIEndpoints.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieEndpoints.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | struct APIEndpoints { 11 | 12 | static func movies(query: String, page: Int) -> DataEndpoint { 13 | 14 | return DataEndpoint(path: "3/search/movie/", 15 | queryParameters: ["query": query, 16 | "page": "\(page)"]) 17 | } 18 | 19 | static func moviePoster(path: String, width: Int) -> DataEndpoint { 20 | 21 | let sizes = [92, 185, 500, 780] 22 | let availableWidth = sizes.sorted().first { width <= $0 } ?? sizes.last 23 | return DataEndpoint(path: "t/p/w\(availableWidth!)\(path)") 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Network/DataMapping/Movie+Mapping.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Movie+Decodable.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh on 22.09.18. 6 | // 7 | 8 | import Foundation 9 | 10 | extension MoviesPage: Decodable { 11 | 12 | private enum CodingKeys: String, CodingKey { 13 | case page 14 | case totalPages = "total_pages" 15 | case movies = "results" 16 | } 17 | 18 | init(from decoder: Decoder) throws { 19 | let container = try decoder.container(keyedBy: CodingKeys.self) 20 | self.page = try container.decode(Int.self, forKey: .page) 21 | self.totalPages = try container.decode(Int.self, forKey: .totalPages) 22 | self.movies = try container.decode([Movie].self, forKey: .movies) 23 | } 24 | } 25 | 26 | extension Movie: Decodable { 27 | private enum CodingKeys: String, CodingKey { 28 | 29 | case id 30 | case title 31 | case posterPath = "poster_path" 32 | case overview 33 | case releaseDate = "release_date" 34 | } 35 | 36 | init(from decoder: Decoder) throws { 37 | let container = try decoder.container(keyedBy: CodingKeys.self) 38 | self.id = try container.decode(Int.self, forKey: .id) 39 | self.title = try container.decode(String.self, forKey: .title) 40 | self.posterPath = try container.decodeIfPresent(String.self, forKey: .posterPath) 41 | self.overview = try container.decode(String.self, forKey: .overview) 42 | let releaseDateString = try container.decode(String.self, forKey: .releaseDate) 43 | releaseDate = DateFormatter.yyyyMMdd.date(from: releaseDateString) 44 | } 45 | } 46 | 47 | fileprivate extension DateFormatter { 48 | static let yyyyMMdd: DateFormatter = { 49 | let formatter = DateFormatter() 50 | formatter.dateFormat = "yyyy-MM-dd" 51 | formatter.calendar = Calendar(identifier: .iso8601) 52 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 53 | formatter.locale = Locale(identifier: "en_US_POSIX") 54 | return formatter 55 | }() 56 | } 57 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/MoviesRecentQueriesStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesQueriesStorage.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh on 03.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol MoviesQueriesStorageInterface { 11 | func recentsQueries(number: Int) -> [MovieQuery] 12 | func saveRecentQuery(query: MovieQuery) 13 | } 14 | 15 | struct MovieQueriesList: Codable { 16 | var list: [MovieQuery] 17 | } 18 | 19 | extension MovieQuery: Codable { 20 | private enum CodingKeys: String, CodingKey { 21 | case query 22 | } 23 | 24 | init(from decoder: Decoder) throws { 25 | let container = try decoder.container(keyedBy: CodingKeys.self) 26 | self.query = try container.decode(String.self, forKey: .query) 27 | } 28 | 29 | func encode(to encoder: Encoder) throws { 30 | var container = encoder.container(keyedBy: CodingKeys.self) 31 | try container.encode(query, forKey: .query) 32 | } 33 | } 34 | 35 | class MoviesQueriesStorage { 36 | private let userDefaultsKey = "recentsMoviesQueries" 37 | private let maxRecentsCount = 20 38 | private var userDefaults: UserDefaults { return UserDefaults.standard } 39 | private var moviesQuries: [MovieQuery] { 40 | get { 41 | if let queriesData = userDefaults.object(forKey: userDefaultsKey) as? Data { 42 | let decoder = JSONDecoder() 43 | if let movieQueryList = try? decoder.decode(MovieQueriesList.self, from: queriesData) { 44 | return movieQueryList.list 45 | } 46 | } 47 | return [] 48 | } 49 | set { 50 | let encoder = JSONEncoder() 51 | if let encoded = try? encoder.encode(MovieQueriesList(list: newValue)) { 52 | userDefaults.set(encoded, forKey: userDefaultsKey) 53 | } 54 | } 55 | } 56 | } 57 | 58 | extension MoviesQueriesStorage: MoviesQueriesStorageInterface { 59 | func recentsQueries(number: Int) -> [MovieQuery] { 60 | let queries = moviesQuries 61 | let subrangeQueries = queries.count < number ? queries : Array(queries[0..) -> Void) -> Cancellable? { 22 | let endpoint = APIEndpoints.movies(query: query.query, page: page) 23 | 24 | return self.dataTransferService.request(with: endpoint) { (response: Result) in 25 | switch response { 26 | case .success(let moviesPage): 27 | result(.success(moviesPage)) 28 | return 29 | case .failure(let error): 30 | result(.failure(error)) 31 | return 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Repositories/MoviesQueriesDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesQueriesDataSource.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 15.02.19. 6 | // 7 | 8 | import Foundation 9 | 10 | class MoviesQueriesDataSource { 11 | 12 | private let dataTransferService: DataTransferInterface 13 | private var moviesQueriesPersistentStorage: MoviesQueriesStorageInterface 14 | 15 | init(dataTransferService: DataTransferInterface, moviesQueriesPersistentStorage: MoviesQueriesStorageInterface) { 16 | self.dataTransferService = dataTransferService 17 | self.moviesQueriesPersistentStorage = moviesQueriesPersistentStorage 18 | } 19 | } 20 | 21 | extension MoviesQueriesDataSource: MoviesQueriesDataSourceInterface { 22 | 23 | func recentsQueries(number: Int) -> [MovieQuery] { 24 | return moviesQueriesPersistentStorage.recentsQueries(number: number) 25 | } 26 | 27 | func saveRecentQuery(query: MovieQuery) { 28 | moviesQueriesPersistentStorage.saveRecentQuery(query: query) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Repositories/PosterImagesDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PosterImagesDataSource.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | class PosterImagesDataSource { 11 | 12 | let dataTransferService: DataTransferInterface 13 | let imageNotFoundData: Data? 14 | 15 | init(dataTransferService: DataTransferInterface, 16 | imageNotFoundData: Data?) { 17 | self.dataTransferService = dataTransferService 18 | self.imageNotFoundData = imageNotFoundData 19 | } 20 | } 21 | 22 | extension PosterImagesDataSource: PosterImagesDataSourceInterface { 23 | 24 | func image(with imagePath: String, width: Int, result: @escaping (Result) -> Void) -> Cancellable? { 25 | let endpoint = APIEndpoints.moviePoster(path: imagePath, width: width) 26 | 27 | return dataTransferService.request(with: endpoint) { [weak self] (response: Result) in 28 | guard let weakSelf = self else { return } 29 | 30 | switch response { 31 | case .success(let data): 32 | result(.success(data)) 33 | return 34 | case .failure(let error): 35 | if case let DataTransferError.networkFailure(networkError) = error, networkError.isNotFoundError, 36 | let imageNotFoundData = weakSelf.imageNotFoundData { 37 | result(.success(imageNotFoundData)) 38 | return 39 | } 40 | result(.failure(error)) 41 | return 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Entities/Movie.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Movie.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // Note: All properties in Domain Entities are let, not var 7 | 8 | import Foundation 9 | 10 | typealias MovieId = Int 11 | 12 | struct MoviesPage { 13 | let page: Int 14 | let totalPages: Int 15 | let movies: [Movie] 16 | } 17 | 18 | struct Movie { 19 | let id: MovieId 20 | let title: String 21 | let posterPath: String? 22 | let overview: String 23 | let releaseDate: Date? 24 | } 25 | 26 | extension Movie: Equatable { 27 | static func == (lhs: Movie, rhs: Movie) -> Bool { 28 | return (lhs.id == rhs.id) 29 | } 30 | } 31 | 32 | extension Movie: Hashable { 33 | func hash(into hasher: inout Hasher) { 34 | hasher.combine(id) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Entities/MovieOffer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieOffer.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 17.06.19. 6 | // 7 | // Note: All properties in Domain Entities are let, not var 8 | 9 | import Foundation 10 | 11 | typealias MovieOfferId = Int 12 | 13 | struct MovieOffer { 14 | let id: MovieOfferId 15 | let name: String 16 | } 17 | 18 | extension MovieOffer: Equatable { 19 | static func == (lhs: MovieOffer, rhs: MovieOffer) -> Bool { 20 | return (lhs.id == rhs.id) 21 | } 22 | } 23 | 24 | extension MovieOffer: Hashable { 25 | func hash(into hasher: inout Hasher) { 26 | hasher.combine(id) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Entities/MovieQuery.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MovieQuery.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.10.18. 6 | // 7 | // Note: All properties in Domain Entities are let, not var 8 | 9 | import Foundation 10 | 11 | struct MovieQuery { 12 | let query: String 13 | } 14 | 15 | extension MovieQuery: Equatable { 16 | static func == (lhs: MovieQuery, rhs: MovieQuery) -> Bool { 17 | return (lhs.query == rhs.query) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Interfaces/Repositories/MoviesDataSourceInterface.swift: -------------------------------------------------------------------------------- 1 | // 2 | // moviesDataSourceInterface.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol MoviesDataSourceInterface { 11 | @discardableResult 12 | func moviesList(query: MovieQuery, page: Int, with result: @escaping (Result) -> Void) -> Cancellable? 13 | } 14 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Interfaces/Repositories/MoviesQueriesDataSourceInterface.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesQueriesDataSourceInterface.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 15.02.19. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol MoviesQueriesDataSourceInterface { 11 | 12 | func recentsQueries(number: Int) -> [MovieQuery] 13 | func saveRecentQuery(query: MovieQuery) 14 | } 15 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Interfaces/Repositories/PosterImagesDataSourceInterface.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PosterImagesDataSourceInterface.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol PosterImagesDataSourceInterface { 11 | 12 | func image(with imagePath: String, width: Int, result: @escaping (Result) -> Void) -> Cancellable? 13 | } 14 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/UseCases/FetchMovieOfferUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchMovieOfferUseCase.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 17.06.19. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol FetchMovieOfferUseCaseInterface { 11 | func execute(requestValue: FetchMovieOfferUseCaseRequestValue, 12 | completion: @escaping (Result) -> Void) 13 | } 14 | 15 | class FetchMovieOfferUseCase: FetchMovieOfferUseCaseInterface { 16 | 17 | func execute(requestValue: FetchMovieOfferUseCaseRequestValue, 18 | completion: @escaping (Result) -> Void) { 19 | 20 | DispatchQueue.global(qos: .background).async { 21 | // TODO: Implement download movie offer request and remove sleep() 22 | sleep(1) 23 | completion(.success(MovieOffer(id: 0, name: "Movie Offer"))) 24 | } 25 | } 26 | } 27 | 28 | struct FetchMovieOfferUseCaseRequestValue { 29 | let query: MovieQuery 30 | } 31 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/UseCases/FetchMoviesUseCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchMoviesUseCase.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 22.02.19. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol FetchMoviesUseCaseInterface { 11 | func execute(requestValue: FetchMoviesUseCaseRequestValue, 12 | completion: @escaping (Result) -> Void) -> Cancellable? 13 | } 14 | 15 | class FetchMoviesUseCase: FetchMoviesUseCaseInterface { 16 | let moviesDataSource: MoviesDataSourceInterface 17 | let moviesQueriesDataSource: MoviesQueriesDataSourceInterface 18 | 19 | init(moviesDataSource: MoviesDataSourceInterface, moviesQueriesDataSource: MoviesQueriesDataSourceInterface) { 20 | self.moviesDataSource = moviesDataSource 21 | self.moviesQueriesDataSource = moviesQueriesDataSource 22 | } 23 | 24 | func execute(requestValue: FetchMoviesUseCaseRequestValue, 25 | completion: @escaping (Result) -> Void) -> Cancellable? { 26 | return moviesDataSource.moviesList(query: requestValue.query, page: requestValue.page) { [weak self] result in 27 | guard let weakSelf = self else { return } 28 | 29 | switch result { 30 | case .success: 31 | weakSelf.moviesQueriesDataSource.saveRecentQuery(query: requestValue.query) 32 | return completion(result) 33 | case .failure: 34 | return completion(result) 35 | } 36 | } 37 | } 38 | } 39 | 40 | struct FetchMoviesUseCaseRequestValue { 41 | let query: MovieQuery 42 | let page: Int 43 | } 44 | -------------------------------------------------------------------------------- /ExampleMVVM/Infrastructure/Network/DataTransfer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dispatcher.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum DataTransferError: Error { 11 | case noResponse 12 | case parsingJSON 13 | case networkFailure(NetworkError) 14 | } 15 | 16 | public final class DataEndpoint: Endpoint { } 17 | 18 | public protocol DataTransferInterface { 19 | @discardableResult 20 | func request(with endpoint: DataEndpoint, completion: @escaping (Result) -> Void) -> Cancellable? 21 | @discardableResult 22 | func request(with endpoint: DataEndpoint, completion: @escaping (Result) -> Void) -> Cancellable? 23 | @discardableResult 24 | func request(with endpoint: DataEndpoint, respondOnQueue: DispatchQueue, completion: @escaping (Result) -> Void) -> Cancellable? 25 | @discardableResult 26 | func request(with endpoint: DataEndpoint, respondOnQueue: DispatchQueue, completion: @escaping (Result) -> Void) -> Cancellable? 27 | } 28 | 29 | public class DataTransferService { 30 | 31 | private let networkService: NetworkServiceInterface 32 | 33 | public init(with networkService: NetworkServiceInterface) { 34 | self.networkService = networkService 35 | } 36 | } 37 | 38 | extension DataTransferService: DataTransferInterface { 39 | 40 | public func request(with endpoint: DataEndpoint, completion: @escaping (Result) -> Void) -> Cancellable? where T : Decodable { 41 | return request(with: endpoint, respondOnQueue: .main, completion: completion) 42 | } 43 | 44 | public func request(with endpoint: DataEndpoint, completion: @escaping (Result) -> Void) -> Cancellable? { 45 | return request(with: endpoint, respondOnQueue: .main, completion: completion) 46 | } 47 | 48 | public func request(with endpoint: DataEndpoint, respondOnQueue: DispatchQueue, completion: @escaping (Result) -> Void) -> Cancellable? { 49 | 50 | let task = self.networkService.request(endpoint: endpoint) { result in 51 | switch result { 52 | case .success(let responseData): 53 | guard let responseData = responseData else { 54 | respondOnQueue.async { completion(Result.failure(DataTransferError.noResponse)) } 55 | return 56 | } 57 | do { 58 | let decoder = JSONDecoder() 59 | let result = try decoder.decode(T.self, from: responseData) 60 | respondOnQueue.async { completion(.success(result)) } 61 | } 62 | catch { 63 | respondOnQueue.async { completion(Result.failure(DataTransferError.parsingJSON)) } 64 | } 65 | case .failure(let error): 66 | respondOnQueue.async { completion(Result.failure(DataTransferError.networkFailure(error))) } 67 | } 68 | } 69 | 70 | return task 71 | } 72 | 73 | public func request(with endpoint: DataEndpoint, respondOnQueue: DispatchQueue, completion: @escaping (Result) -> Void) -> Cancellable? { 74 | let task = self.networkService.request(endpoint: endpoint) { result in 75 | switch result { 76 | case .success(let responseData): 77 | guard let responseData = responseData 78 | else { 79 | respondOnQueue.async { completion(Result.failure(DataTransferError.noResponse)) } 80 | return 81 | } 82 | respondOnQueue.async { completion(Result.success(responseData)) } 83 | case .failure(let error): 84 | respondOnQueue.async { completion(Result.failure(DataTransferError.networkFailure(error))) } 85 | } 86 | } 87 | 88 | return task 89 | } 90 | } 91 | 92 | -------------------------------------------------------------------------------- /ExampleMVVM/Infrastructure/Network/Endpoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Endpoint.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum HTTPMethodType: String { 11 | case get = "GET" 12 | case head = "HEAD" 13 | case post = "POST" 14 | case put = "PUT" 15 | case delete = "DELETE" 16 | } 17 | 18 | public enum BodyEncoding { 19 | case jsonSerializationData 20 | case stringEncodingAscii 21 | } 22 | 23 | public class Endpoint: Requestable { 24 | public var path: String 25 | public var isFullPath: Bool = false 26 | public var method: HTTPMethodType = .get 27 | public var queryParameters: [String: Any] = [:] 28 | public var headerParamaters: [String: String] = [:] 29 | public var bodyParamaters: [String : Any] = [:] 30 | public var bodyEncoding: BodyEncoding = .jsonSerializationData 31 | 32 | init(path: String, 33 | isFullPath: Bool = false, 34 | method: HTTPMethodType = .get, 35 | queryParameters: [String: Any] = [:], 36 | headerParamaters: [String: String] = [:], 37 | bodyParamaters: [String : Any] = [:], 38 | bodyEncoding: BodyEncoding = .jsonSerializationData) { 39 | self.path = path 40 | self.isFullPath = isFullPath 41 | self.method = method 42 | self.queryParameters = queryParameters 43 | self.headerParamaters = headerParamaters 44 | self.bodyParamaters = bodyParamaters 45 | self.bodyEncoding = bodyEncoding 46 | } 47 | } 48 | 49 | public protocol Requestable { 50 | var path: String { get } 51 | var isFullPath: Bool { get } 52 | var method: HTTPMethodType { get } 53 | var queryParameters: [String: Any] { get } 54 | var headerParamaters: [String: String] { get } 55 | var bodyParamaters: [String: Any] { get } 56 | var bodyEncoding: BodyEncoding { get } 57 | 58 | func urlRequest(with networkConfig: NetworkConfigurable) throws -> URLRequest 59 | } 60 | 61 | enum RequestGenerationError: Error { 62 | case components 63 | } 64 | 65 | extension Requestable { 66 | 67 | func url(with config: NetworkConfigurable) throws -> URL { 68 | 69 | let baseURL = config.baseURL.absoluteString.last != "/" ? config.baseURL.absoluteString + "/" : config.baseURL.absoluteString 70 | let endpoint = isFullPath ? path : baseURL.appending(path) 71 | 72 | guard var urlComponents = URLComponents(string: endpoint) else { throw RequestGenerationError.components} 73 | var urlQueryItems = [URLQueryItem]() 74 | 75 | queryParameters.forEach { 76 | urlQueryItems.append(URLQueryItem(name: $0.key, value: "\($0.value)")) 77 | } 78 | config.queryParameters.forEach { 79 | urlQueryItems.append(URLQueryItem(name: $0.key, value: $0.value)) 80 | } 81 | urlComponents.queryItems = !urlQueryItems.isEmpty ? urlQueryItems : nil 82 | guard let url = urlComponents.url else { throw RequestGenerationError.components } 83 | return url 84 | } 85 | 86 | public func urlRequest(with config: NetworkConfigurable) throws -> URLRequest { 87 | 88 | let url = try self.url(with: config) 89 | var urlRequest = URLRequest(url: url) 90 | var allHeaders: [String: String] = config.headers 91 | headerParamaters.forEach({ allHeaders.updateValue($1, forKey: $0) }) 92 | 93 | if !bodyParamaters.isEmpty { 94 | urlRequest.httpBody = encodeBody(bodyParamaters: bodyParamaters, bodyEncoding: bodyEncoding) 95 | } 96 | urlRequest.httpMethod = method.rawValue 97 | urlRequest.allHTTPHeaderFields = allHeaders 98 | return urlRequest 99 | } 100 | 101 | fileprivate func encodeBody(bodyParamaters: [String : Any], bodyEncoding: BodyEncoding) -> Data? { 102 | switch bodyEncoding { 103 | case .jsonSerializationData: 104 | return try? JSONSerialization.data(withJSONObject: bodyParamaters) 105 | case .stringEncodingAscii: 106 | return bodyParamaters.queryString.data(using: String.Encoding.ascii, allowLossyConversion: true) 107 | } 108 | } 109 | } 110 | 111 | fileprivate extension Dictionary { 112 | var queryString: String { 113 | 114 | return self.map { "\($0.key)=\($0.value)" } 115 | .joined(separator: "&") 116 | .addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) ?? "" 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /ExampleMVVM/Infrastructure/Network/Network.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkService.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URLSessionDataTask: Cancellable { } 11 | 12 | public protocol NetworkServiceInterface { 13 | 14 | func request(endpoint: Requestable, completion: @escaping (Result) -> Void) -> Cancellable? 15 | } 16 | 17 | public enum NetworkError: Error { 18 | case errorStatusCode(statusCode: Int) 19 | case notConnected 20 | case cancelled 21 | case urlGeneration 22 | case requestError(Error?) 23 | } 24 | 25 | extension NetworkError { 26 | var isNotFoundError: Bool { return hasCodeError(404) } 27 | 28 | func hasCodeError(_ codeError: Int) -> Bool { 29 | switch self { 30 | case let .errorStatusCode(code): 31 | return code == codeError 32 | default: return false 33 | } 34 | } 35 | } 36 | 37 | public protocol NetworkSession { 38 | func loadData(from request: URLRequest, 39 | completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask 40 | } 41 | 42 | extension URLSession: NetworkSession { 43 | public func loadData(from request: URLRequest, 44 | completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { 45 | let task = dataTask(with: request) { (data, response, error) in 46 | completionHandler(data, response, error) 47 | } 48 | task.resume() 49 | return task 50 | } 51 | } 52 | 53 | // MARK: - Implementation 54 | 55 | public class NetworkService { 56 | 57 | private let session: NetworkSession 58 | private let config: NetworkConfigurable 59 | 60 | public init(session: NetworkSession, config: NetworkConfigurable) { 61 | self.session = session 62 | self.config = config 63 | } 64 | 65 | private func request(request: URLRequest, completion: @escaping (Result) -> Void) -> Cancellable { 66 | 67 | let sessionDataTask = session.loadData(from: request) { (data, response, requestError) in 68 | var error: NetworkError 69 | if let requestError = requestError { 70 | 71 | if let response = response as? HTTPURLResponse, (400..<600).contains(response.statusCode) { 72 | error = .errorStatusCode(statusCode: response.statusCode) 73 | Logger.log(statusCode: response.statusCode) 74 | } else if requestError._code == NSURLErrorNotConnectedToInternet { 75 | error = .notConnected 76 | } else if requestError._code == NSURLErrorCancelled { 77 | error = .cancelled 78 | } else { 79 | error = .requestError(requestError) 80 | } 81 | Logger.log(error: requestError) 82 | completion(.failure(error)) 83 | } 84 | else { 85 | Logger.log(responseData: data, response: response) 86 | completion(.success(data)) 87 | } 88 | } 89 | sessionDataTask.resume() 90 | 91 | Logger.log(request: request) 92 | 93 | return sessionDataTask 94 | } 95 | } 96 | 97 | extension NetworkService: NetworkServiceInterface { 98 | 99 | public func request(endpoint: Requestable, completion: @escaping (Result) -> Void) -> Cancellable? { 100 | do { 101 | let urlRequest = try endpoint.urlRequest(with: config) 102 | return request(request: urlRequest, completion: completion) 103 | } catch { 104 | completion(.failure(NetworkError.urlGeneration)) 105 | return nil 106 | } 107 | } 108 | } 109 | 110 | // MARK: - Log 111 | class Logger { 112 | static func log(request: URLRequest) { 113 | #if DEBUG 114 | print("-------------") 115 | print("request: \(request.url!)") 116 | print("headers: \(request.allHTTPHeaderFields!)") 117 | print("method: \(request.httpMethod!)") 118 | if let httpBody = request.httpBody, let result = ((try? JSONSerialization.jsonObject(with: httpBody, options: []) as? [String:AnyObject]) as [String : AnyObject]??) { 119 | print("body: \(String(describing: result))") 120 | } 121 | if let httpBody = request.httpBody, let resultString = String(data: httpBody, encoding: .utf8) { 122 | print("body: \(String(describing: resultString))") 123 | } 124 | #endif 125 | } 126 | 127 | static func log(responseData data: Data?, response: URLResponse?) { 128 | #if DEBUG 129 | guard let data = data else { return } 130 | if let dataDict = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { 131 | print("responseData: \(String(describing: dataDict))") 132 | } 133 | #endif 134 | } 135 | 136 | static func log(error: Error) { 137 | #if DEBUG 138 | print("error: \(error)") 139 | #endif 140 | } 141 | 142 | static func log(statusCode: Int) { 143 | #if DEBUG 144 | print("status code: \(statusCode)") 145 | #endif 146 | } 147 | } 148 | 149 | -------------------------------------------------------------------------------- /ExampleMVVM/Infrastructure/Network/NetworkConfig.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServiceConfig.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol NetworkConfigurable { 11 | var baseURL: URL { get } 12 | var headers: [String: String] { get } 13 | var apiKey: String? { get } 14 | var queryParameters: [String: String] { get } 15 | } 16 | 17 | public struct ApiDataNetworkConfig: NetworkConfigurable { 18 | public let baseURL: URL 19 | public let headers: [String: String] 20 | public let apiKey: String? 21 | public var queryParameters: [String: String] { 22 | guard let apiKey = apiKey else { return [:] } 23 | return ["api_key": apiKey] 24 | } 25 | 26 | public init(baseURL: URL, headers: [String: String] = [:], apiKey: String? = nil) { 27 | self.baseURL = baseURL 28 | self.headers = headers 29 | self.apiKey = apiKey 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/Coordinator/MoviesListViewCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesListViewCoordinator.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.03.19. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol MoviesListViewCoordinatorDependencies { 11 | func makeFetchMoviesUseCase() -> FetchMoviesUseCaseInterface 12 | func makeFetchMovieOfferUseCase() -> FetchMovieOfferUseCaseInterface 13 | func makePosterImagesDataSource() -> PosterImagesDataSourceInterface 14 | } 15 | 16 | class MoviesListViewCoordinator: Coordinator { 17 | 18 | var childCoordinators: [Coordinator] = [] 19 | var navigationController: UINavigationController 20 | 21 | typealias DependenciesType = MoviesListViewCoordinatorDependencies & MoviesQueriesListCoordinatorDependencies 22 | private let dependencies: DependenciesType 23 | 24 | private weak var moviesListViewController: MoviesListViewController? 25 | 26 | init(navigationController: UINavigationController, 27 | dependencies: DependenciesType) { 28 | self.navigationController = navigationController 29 | self.dependencies = dependencies 30 | } 31 | 32 | func start() { 33 | let viewModel = MoviesListViewModel(fetchMoviesUseCase: dependencies.makeFetchMoviesUseCase(), 34 | fetchMovieOfferUseCase: dependencies.makeFetchMovieOfferUseCase(), 35 | posterImagesDataSource: dependencies.makePosterImagesDataSource(), 36 | delegate: self) 37 | 38 | let vc = MoviesListViewController.instantiate(with: viewModel) 39 | navigationController.pushViewController(vc, animated: false) 40 | moviesListViewController = vc 41 | } 42 | } 43 | 44 | // MARK: - Navigation 45 | 46 | extension MoviesListViewCoordinator: MoviesListViewModelDelegate { 47 | 48 | func showRecentQueries(delegate: MoviesQueriesListViewModelDelegate) { 49 | guard !childCoordinators.contains(where: { ($0 as? MoviesQueriesListCoordinator) != nil }), 50 | let moviesListViewController = moviesListViewController, 51 | let suggestionsListContainer = moviesListViewController.suggestionsListContainer 52 | else { return } 53 | 54 | let moviesQueriesListNC = UINavigationController() 55 | moviesQueriesListNC.isNavigationBarHidden = true 56 | moviesListViewController.add(child: moviesQueriesListNC, container: suggestionsListContainer) 57 | 58 | let coordinator = MoviesQueriesListCoordinator(navigationController: moviesQueriesListNC, 59 | moviesQueriesListViewModelDelegate: delegate, 60 | dependencies: dependencies) 61 | childCoordinators.append(coordinator) 62 | coordinator.start() 63 | } 64 | 65 | func closeRecentQueries() { 66 | guard let coordinator = childCoordinators.first( where: { ($0 as? MoviesQueriesListCoordinator) != nil }) as? MoviesQueriesListCoordinator 67 | else { return } 68 | coordinator.navigationController.remove() 69 | childCoordinators = childCoordinators.filter { $0 !== coordinator } 70 | } 71 | } 72 | 73 | extension UIViewController { 74 | func add(child: UIViewController, container: UIView) { 75 | addChild(child) 76 | container.addSubview(child.view) 77 | child.didMove(toParent: self) 78 | } 79 | func remove() { 80 | guard parent != nil else { 81 | return 82 | } 83 | willMove(toParent: nil) 84 | removeFromParent() 85 | view.removeFromSuperview() 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/View/MoviesListTableView/Cells/MoviesListItemCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesListItemCell.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class MoviesListItemCell: UITableViewCell, MVVMView { 12 | 13 | static let reuseIdentifier = String(describing: MoviesListItemCell.self) 14 | static let height = CGFloat(130) 15 | 16 | @IBOutlet weak var titleLabel: UILabel! 17 | @IBOutlet weak var dateLabel: UILabel! 18 | @IBOutlet weak var overviewLabel: UILabel! 19 | @IBOutlet weak var posterImageView: UIImageView! 20 | 21 | var viewModel: MoviesListViewModel.Item! { didSet { unbind(from: oldValue) } } 22 | 23 | func fill(with viewModel: MoviesListViewModel.Item) { 24 | self.viewModel = viewModel 25 | titleLabel.text = viewModel.title 26 | dateLabel.text = "\(NSLocalizedString("Release Date", comment: "")): \(viewModel.releaseDate)" 27 | overviewLabel.text = viewModel.overview 28 | viewModel.updatePosterImage(width: Int(posterImageView.frame.size.width * UIScreen.main.scale)) 29 | 30 | bind(to: viewModel) 31 | } 32 | 33 | func bind(to viewModel: MoviesListViewModel.Item) { 34 | viewModel.posterImage.observeAndFire(on: self) { [weak self] (data: Data?) in 35 | self?.posterImageView.image = data.flatMap{ UIImage(data: $0) } 36 | } 37 | } 38 | 39 | private func unbind(from item: MoviesListViewModel.Item?) { 40 | item?.posterImage.remove(observer: self) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/View/MoviesListTableView/MoviesListTableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesListTableViewController.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class MoviesListTableViewController: UITableViewController { 12 | 13 | var nextPageLoadingSpinner: UIActivityIndicatorView? 14 | 15 | var viewModel: MoviesListViewModel! 16 | 17 | var items: [MoviesListViewModel.Item]! { 18 | didSet { reload() } 19 | } 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | tableView.estimatedRowHeight = MoviesListItemCell.height 24 | tableView.rowHeight = UITableView.automaticDimension 25 | tableView.allowsSelection = false 26 | } 27 | 28 | func reload() { 29 | tableView.reloadData() 30 | } 31 | 32 | func update(isLoadingNextPage: Bool) { 33 | if isLoadingNextPage { 34 | nextPageLoadingSpinner?.removeFromSuperview() 35 | nextPageLoadingSpinner = UIActivityIndicatorView(style: .gray) 36 | nextPageLoadingSpinner?.startAnimating() 37 | nextPageLoadingSpinner?.isHidden = false 38 | nextPageLoadingSpinner?.frame = CGRect(x: CGFloat(0), y: CGFloat(0), width: tableView.frame.width, height: 44) 39 | tableView.tableFooterView = nextPageLoadingSpinner 40 | } 41 | else { 42 | tableView.tableFooterView = nil 43 | } 44 | } 45 | } 46 | 47 | // MARK: - UITableViewDataSource, UITableViewDelegate 48 | extension MoviesListTableViewController { 49 | 50 | override func numberOfSections(in tableView: UITableView) -> Int { 51 | return 1 52 | } 53 | 54 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 55 | return viewModel.items.value.count 56 | } 57 | 58 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 59 | let cell = tableView.dequeueReusableCell(withIdentifier: MoviesListItemCell.reuseIdentifier, for: indexPath) as! MoviesListItemCell 60 | 61 | cell.fill(with: viewModel.items.value[indexPath.row]) 62 | 63 | if indexPath.row == viewModel.items.value.count - 1 { 64 | viewModel.didLoadNextPage() 65 | } 66 | 67 | return cell 68 | } 69 | 70 | override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 71 | return viewModel.isEmpty ? tableView.frame.height : super.tableView(tableView, heightForRowAt: indexPath) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/View/MoviesListViewController.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 36 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 117 | 123 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/View/MoviesListViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesListViewController.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class MoviesListViewController: UIViewController, MVVMView, StoryboardInstantiable, Alertable { 12 | 13 | @IBOutlet weak var contentView: UIView! 14 | @IBOutlet weak var moviesListContainer: UIView! 15 | @IBOutlet weak var suggestionsListContainer: UIView! 16 | @IBOutlet weak var searchBarContainer: UIView! 17 | @IBOutlet weak var loadingView: UIActivityIndicatorView! 18 | @IBOutlet weak var emptyDataLabel: UILabel! 19 | 20 | var viewModel: MoviesListViewModel! 21 | 22 | private var moviesTableViewController: MoviesListTableViewController? 23 | var searchController = UISearchController(searchResultsController: nil) 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | title = NSLocalizedString("Movies", comment: "") 28 | emptyDataLabel.text = NSLocalizedString("Search results ", comment: "") 29 | setupSearchController() 30 | 31 | bind(to: viewModel) 32 | viewModel.viewDidLoad() 33 | } 34 | 35 | func bind(to viewModel: MoviesListViewModel) { 36 | viewModel.items.observe(on: self) { [unowned self] (items: [MoviesListViewModel.Item]) in 37 | self.moviesTableViewController?.items = items 38 | self.updateViewsVisibility(model: self.viewModel) 39 | } 40 | viewModel.query.observe(on: self) { [unowned self] (query: String) in 41 | self.updateSearchController(query: query) 42 | } 43 | viewModel.error.observe(on: self) { [unowned self] (error: String) in 44 | self.showError(error) 45 | } 46 | viewModel.loadingType.observeAndFire(on: self) { [unowned self] _ in 47 | self.updateViewsVisibility(model: self.viewModel) 48 | } 49 | } 50 | 51 | override func viewWillDisappear(_ animated: Bool) { 52 | super.viewWillDisappear(animated) 53 | searchController.isActive = false 54 | } 55 | 56 | private func updateSearchController(query: String) { 57 | searchController.isActive = false 58 | searchController.searchBar.text = query 59 | } 60 | 61 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 62 | if segue.identifier == String(describing: MoviesListTableViewController.self), 63 | let destinationVC = segue.destination as? MoviesListTableViewController { 64 | 65 | moviesTableViewController = destinationVC 66 | moviesTableViewController?.viewModel = viewModel 67 | } 68 | } 69 | 70 | func showError(_ error: String) { 71 | guard !error.isEmpty else { return } 72 | showAlert(title: NSLocalizedString("Error", comment: ""), message: error) 73 | } 74 | 75 | private func updateViewsVisibility(model: MoviesListViewModel) { 76 | loadingView.isHidden = true 77 | emptyDataLabel.isHidden = true 78 | moviesListContainer.isHidden = true 79 | suggestionsListContainer.isHidden = true 80 | moviesTableViewController?.update(isLoadingNextPage: false) 81 | 82 | if model.loadingType.value == .fullScreen { 83 | loadingView.isHidden = false 84 | } else if model.loadingType.value == .nextPage { 85 | moviesTableViewController?.update(isLoadingNextPage: true) 86 | moviesListContainer.isHidden = false 87 | } else if model.isEmpty { 88 | emptyDataLabel.isHidden = false 89 | } else { 90 | moviesListContainer.isHidden = false 91 | } 92 | updateQuerySuggestionsVisibility() 93 | } 94 | 95 | private func updateQuerySuggestionsVisibility() { 96 | setQueriesSuggestionsView(isVisible: searchController.searchBar.isFirstResponder) 97 | } 98 | 99 | private func setQueriesSuggestionsView(isVisible: Bool) { 100 | if isVisible { 101 | viewModel.showQueriesSuggestions() 102 | } else { 103 | viewModel.closeQueriesSuggestions() 104 | } 105 | suggestionsListContainer.isHidden = !isVisible 106 | } 107 | } 108 | 109 | extension MoviesListViewController: UISearchBarDelegate { 110 | func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { 111 | guard let searchText = searchBar.text, !searchText.isEmpty else { return } 112 | searchController.isActive = false 113 | moviesTableViewController?.tableView.setContentOffset(CGPoint.zero, animated: false) 114 | viewModel.didSearch(query: searchText) 115 | } 116 | 117 | func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { 118 | viewModel.didCancelSearch() 119 | } 120 | } 121 | 122 | extension MoviesListViewController: UISearchControllerDelegate { 123 | public func willPresentSearchController(_ searchController: UISearchController) { 124 | updateQuerySuggestionsVisibility() 125 | } 126 | 127 | public func willDismissSearchController(_ searchController: UISearchController) { 128 | updateQuerySuggestionsVisibility() 129 | } 130 | 131 | public func didDismissSearchController(_ searchController: UISearchController) { 132 | updateQuerySuggestionsVisibility() 133 | } 134 | } 135 | 136 | // MARK: - Setup Search Controller 137 | 138 | extension MoviesListViewController { 139 | private func setupSearchController() { 140 | searchController.delegate = self 141 | searchController.searchBar.delegate = self 142 | searchController.searchBar.placeholder = NSLocalizedString("Search Movies", comment: "") 143 | if #available(iOS 9.1, *) { 144 | searchController.obscuresBackgroundDuringPresentation = false 145 | } else { 146 | searchController.dimsBackgroundDuringPresentation = true 147 | } 148 | searchController.searchBar.translatesAutoresizingMaskIntoConstraints = true 149 | searchController.searchBar.barStyle = .black 150 | searchController.searchBar.frame = searchBarContainer.bounds 151 | searchController.searchBar.autoresizingMask = [.flexibleWidth] 152 | searchBarContainer.addSubview(searchController.searchBar) 153 | definesPresentationContext = true 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/ViewModel/MoviesListViewItemModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesListViewItemModel.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 18.02.19. 6 | // 7 | 8 | import Foundation 9 | 10 | extension MoviesListViewModel { 11 | 12 | class Item: MVVMViewModel, Equatable { 13 | 14 | let id: Int 15 | let title: String 16 | let overview: String 17 | let releaseDate: String 18 | var posterPath: String? 19 | private(set) var posterImage: Observable = Observable(nil) 20 | private let posterImagesDataSource: PosterImagesDataSourceInterface 21 | private var imageLoadTask: Cancellable? { willSet { imageLoadTask?.cancel() } } 22 | 23 | init(movie: Movie, 24 | posterImagesDataSource: PosterImagesDataSourceInterface) { 25 | self.id = movie.id 26 | self.title = movie.title 27 | self.posterPath = movie.posterPath 28 | self.overview = movie.overview 29 | self.releaseDate = movie.releaseDate != nil ? dateFormatter.string(from: movie.releaseDate!) : NSLocalizedString("To be announced", comment: "") 30 | self.posterImagesDataSource = posterImagesDataSource 31 | } 32 | 33 | func updatePosterImage(width: Int) { 34 | posterImage.value = nil 35 | guard let posterPath = posterPath else { return } 36 | 37 | imageLoadTask = posterImagesDataSource.image(with: posterPath, width: width) { [weak self] (result: Result) in 38 | guard self?.posterPath == posterPath else { return } 39 | switch result { 40 | case .success(let data): 41 | self?.posterImage.value = data 42 | case .failure: break 43 | } 44 | self?.imageLoadTask = nil 45 | } 46 | } 47 | } 48 | } 49 | 50 | // MARK: - View event methods 51 | extension MoviesListViewModel.Item { 52 | func viewDidLoad() {} 53 | } 54 | 55 | func ==(lhs: MoviesListViewModel.Item, rhs: MoviesListViewModel.Item) -> Bool { 56 | return (lhs.id == rhs.id) 57 | } 58 | 59 | fileprivate let dateFormatter: DateFormatter = { 60 | let formatter = DateFormatter() 61 | formatter.dateStyle = .medium 62 | return formatter 63 | }() 64 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/ViewModel/MoviesListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesListViewModel.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol MoviesListViewModelDelegate: AnyObject { 11 | func showRecentQueries(delegate: MoviesQueriesListViewModelDelegate) 12 | func closeRecentQueries() 13 | } 14 | 15 | class MoviesListViewModel: MVVMViewModel { 16 | 17 | enum LoadingType { 18 | case none 19 | case fullScreen 20 | case nextPage 21 | } 22 | 23 | private(set) var items: Observable<[Item]> = Observable([Item]()) 24 | private(set) var loadingType: Observable = Observable(.none) 25 | private(set) var query: Observable = Observable("") 26 | private(set) var error: Observable = Observable("") 27 | private(set) var currentPage: Int = 0 28 | private(set) var totalPageCount: Int = 1 29 | 30 | var isLoading: Bool { return loadingType.value != .none } 31 | var isEmpty: Bool { return items.value.isEmpty } 32 | var hasMorePages: Bool { 33 | return currentPage < totalPageCount 34 | } 35 | var nextPage: Int { 36 | guard hasMorePages else { return currentPage } 37 | return currentPage + 1 38 | } 39 | 40 | private let fetchMoviesUseCase: FetchMoviesUseCaseInterface 41 | private let fetchMovieOfferUseCase: FetchMovieOfferUseCaseInterface 42 | private let posterImagesDataSource: PosterImagesDataSourceInterface 43 | weak var delegate: MoviesListViewModelDelegate? 44 | 45 | private var moviesLoadTask: Cancellable? { willSet { moviesLoadTask?.cancel() } } 46 | 47 | @discardableResult 48 | init(fetchMoviesUseCase: FetchMoviesUseCaseInterface, 49 | fetchMovieOfferUseCase: FetchMovieOfferUseCaseInterface, 50 | posterImagesDataSource: PosterImagesDataSourceInterface, 51 | delegate: MoviesListViewModelDelegate) { 52 | self.fetchMoviesUseCase = fetchMoviesUseCase 53 | self.fetchMovieOfferUseCase = fetchMovieOfferUseCase 54 | self.posterImagesDataSource = posterImagesDataSource 55 | self.delegate = delegate 56 | } 57 | 58 | func appendPage(moviesPage: MoviesPage) { 59 | self.currentPage = moviesPage.page 60 | self.totalPageCount = moviesPage.totalPages 61 | self.items.value = items.value + moviesPage.movies.map{ MoviesListViewModel.Item(movie: $0, 62 | posterImagesDataSource: posterImagesDataSource) } 63 | } 64 | 65 | private func resetPages() { 66 | currentPage = 0 67 | totalPageCount = 1 68 | items.value.removeAll() 69 | } 70 | 71 | private func load(movieQuery: MovieQuery, loadingType: LoadingType) { 72 | self.loadingType.value = loadingType 73 | self.query.value = movieQuery.query 74 | 75 | let dispatchGroup = DispatchGroup() 76 | 77 | dispatchGroup.enter() 78 | let moviesRequest = FetchMoviesUseCaseRequestValue(query: movieQuery, page: nextPage) 79 | moviesLoadTask = fetchMoviesUseCase.execute(requestValue: moviesRequest) { [weak self] result in 80 | guard let weakSelf = self else { return } 81 | switch result { 82 | case .success(let moviesPage): 83 | weakSelf.appendPage(moviesPage: moviesPage) 84 | case .failure(let error): 85 | weakSelf.handle(error: error) 86 | } 87 | dispatchGroup.leave() 88 | } 89 | 90 | dispatchGroup.enter() 91 | let movieOfferRequest = FetchMovieOfferUseCaseRequestValue(query: movieQuery) 92 | fetchMovieOfferUseCase.execute(requestValue: movieOfferRequest) { [weak self] result in 93 | guard let weakSelf = self else { return } 94 | switch result { 95 | case .success(let moviesOffer): 96 | print("Show movie offer: \(moviesOffer.name)") 97 | case .failure(let error): 98 | weakSelf.handle(error: error) 99 | } 100 | dispatchGroup.leave() 101 | } 102 | 103 | dispatchGroup.notify(queue: .main) { 104 | self.loadingType.value = .none 105 | } 106 | } 107 | 108 | private func handle(error: Error) { 109 | var errorMsg = NSLocalizedString("Failed loading movies", comment: "") 110 | if let error = error as? DataTransferError, case let DataTransferError.networkFailure(networkError) = error { 111 | errorMsg = NSLocalizedString("No internet connection", comment: "") 112 | if case .cancelled = networkError { return } 113 | } 114 | self.error.value = errorMsg 115 | } 116 | 117 | private func update(movieQuery: MovieQuery) { 118 | resetPages() 119 | load(movieQuery: movieQuery, loadingType: .fullScreen) 120 | } 121 | } 122 | 123 | // MARK: - View event methods 124 | extension MoviesListViewModel { 125 | 126 | func viewDidLoad() { 127 | loadingType.value = .none 128 | } 129 | 130 | func didLoadNextPage() { 131 | guard hasMorePages, !isLoading else { return } 132 | load(movieQuery: MovieQuery(query: query.value), 133 | loadingType: .nextPage) 134 | } 135 | 136 | func didSearch(query: String) { 137 | guard !query.isEmpty else { return } 138 | update(movieQuery: MovieQuery(query: query)) 139 | } 140 | 141 | func didCancelSearch() { 142 | moviesLoadTask?.cancel() 143 | } 144 | 145 | func showQueriesSuggestions() { 146 | delegate?.showRecentQueries(delegate: self) 147 | } 148 | 149 | func closeQueriesSuggestions() { 150 | delegate?.closeRecentQueries() 151 | } 152 | } 153 | 154 | // MARK: - Delegate method from another model views 155 | extension MoviesListViewModel: MoviesQueriesListViewModelDelegate { 156 | 157 | func moviesQueriesListDidSelect(movieQuery: MovieQuery) { 158 | update(movieQuery: movieQuery) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/Coordinator/MoviesQueriesListCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesQueriesListCoordinator.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.03.19. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol MoviesQueriesListCoordinatorDependencies { 11 | func makeMoviesQueriesDataSource() -> MoviesQueriesDataSourceInterface 12 | } 13 | 14 | class MoviesQueriesListCoordinator: Coordinator { 15 | var childCoordinators: [Coordinator] = [] 16 | 17 | var navigationController: UINavigationController 18 | 19 | private let moviesQueriesListViewModelDelegate: MoviesQueriesListViewModelDelegate 20 | private let dependencies: MoviesQueriesListCoordinatorDependencies 21 | 22 | init(navigationController: UINavigationController, 23 | moviesQueriesListViewModelDelegate: MoviesQueriesListViewModelDelegate, 24 | dependencies: MoviesQueriesListCoordinatorDependencies) { 25 | self.navigationController = navigationController 26 | self.moviesQueriesListViewModelDelegate = moviesQueriesListViewModelDelegate 27 | self.dependencies = dependencies 28 | } 29 | 30 | func start() { 31 | let viewModel = MoviesQueriesListViewModel(numberOfQueriesToShow: 10, 32 | moviesQueriesDataSource: dependencies.makeMoviesQueriesDataSource(), 33 | delegate: moviesQueriesListViewModelDelegate) 34 | let vc = MoviesQueriesTableViewController.instantiate(with: viewModel) 35 | navigationController.pushViewController(vc, animated: false) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/View/Cells/MoviesQueriesItemCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SuggestionsItemCell.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.10.18. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class MoviesQueriesItemCell: UITableViewCell { 12 | 13 | static let reuseIdentifier = String(describing: MoviesQueriesItemCell.self) 14 | 15 | @IBOutlet weak var titleLabel: UILabel! 16 | 17 | func fill(with suggestion: MoviesQueriesListViewModel.Item) { 18 | self.titleLabel.text = suggestion.query 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/View/MoviesQueriesTableViewController.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/View/MoviesQueriesTableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesQueriesTableViewController.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.10.18. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | class MoviesQueriesTableViewController: UITableViewController, MVVMView, StoryboardInstantiable { 12 | 13 | var viewModel: MoviesQueriesListViewModel! 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | tableView.tableFooterView = UIView() 18 | tableView.backgroundColor = .clear 19 | 20 | bind(to: viewModel) 21 | } 22 | 23 | func bind(to viewModel: MoviesQueriesListViewModel) { 24 | viewModel.items.observe(on: self) { [unowned self] _ in 25 | self.tableView.reloadData() 26 | } 27 | } 28 | 29 | override func viewWillAppear(_ animated: Bool) { 30 | 31 | super.viewWillAppear(animated) 32 | viewModel.viewWillAppear() 33 | } 34 | } 35 | 36 | // MARK: - UITableViewDataSource, UITableViewDelegate 37 | extension MoviesQueriesTableViewController { 38 | 39 | override func numberOfSections(in tableView: UITableView) -> Int { 40 | return 1 41 | } 42 | 43 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 44 | return viewModel.items.value.count 45 | } 46 | 47 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 48 | let cell = tableView.dequeueReusableCell(withIdentifier: MoviesQueriesItemCell.reuseIdentifier, for: indexPath) as! MoviesQueriesItemCell 49 | cell.fill(with: viewModel.items.value[indexPath.row]) 50 | 51 | return cell 52 | } 53 | 54 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 55 | tableView.deselectRow(at: indexPath, animated: false) 56 | viewModel.didSelect(item: viewModel.items.value[indexPath.row]) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/ViewModel/MoviesQueriesListViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesQueriesListViewModel.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.10.18. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol MoviesQueriesListViewModelDelegate { 11 | func moviesQueriesListDidSelect(movieQuery: MovieQuery) 12 | } 13 | 14 | class MoviesQueriesListViewModel: MVVMViewModel { 15 | 16 | struct Item: Equatable { 17 | let query: String 18 | } 19 | 20 | private(set) var items: Observable<[Item]> = Observable([Item]()) 21 | 22 | let numberOfQueriesToShow: Int 23 | let moviesQueriesDataSource: MoviesQueriesDataSourceInterface 24 | let delegate: MoviesQueriesListViewModelDelegate 25 | 26 | init(numberOfQueriesToShow: Int, 27 | moviesQueriesDataSource: MoviesQueriesDataSourceInterface, 28 | delegate: MoviesQueriesListViewModelDelegate) { 29 | self.numberOfQueriesToShow = numberOfQueriesToShow 30 | self.moviesQueriesDataSource = moviesQueriesDataSource 31 | self.delegate = delegate 32 | } 33 | 34 | private func updateMoviesQueries() { 35 | self.items.value = moviesQueriesDataSource.recentsQueries(number: numberOfQueriesToShow).map{ Item(query: $0.query) } 36 | } 37 | } 38 | 39 | // MARK: - Event methods from view 40 | extension MoviesQueriesListViewModel { 41 | func viewWillAppear() { 42 | updateMoviesQueries() 43 | } 44 | 45 | func viewDidLoad() { 46 | 47 | } 48 | 49 | func didSelect(item: MoviesQueriesListViewModel.Item) { 50 | delegate.moviesQueriesListDidSelect(movieQuery: MovieQuery(query: item.query)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesSceneDIContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MoviesSceneDIContainer.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.03.19. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | protocol MoviesSceneDIContainerDependencies { 12 | var apiDataTransferService: DataTransferInterface { get } 13 | var imageDataTransferService: DataTransferInterface { get } 14 | } 15 | 16 | // Dependencies that exist only during life cycle of the movie scene 17 | class MoviesSceneDIContainer { 18 | 19 | private let dependencies: MoviesSceneDIContainerDependencies 20 | 21 | init(dependencies: MoviesSceneDIContainerDependencies) { 22 | self.dependencies = dependencies 23 | } 24 | 25 | // MARK: - Persistent Storage 26 | lazy var moviesQueriesStorage = MoviesQueriesStorage() 27 | 28 | // MARK: - Use Cases 29 | func makeFetchMoviesUseCase() -> FetchMoviesUseCaseInterface { 30 | return FetchMoviesUseCase(moviesDataSource: makeMoviesDataSource(), 31 | moviesQueriesDataSource: makeMoviesQueriesDataSource()) 32 | } 33 | 34 | func makeFetchMovieOfferUseCase() -> FetchMovieOfferUseCaseInterface { 35 | return FetchMovieOfferUseCase() 36 | } 37 | 38 | // MARK: - Data Sources 39 | func makeMoviesDataSource() -> MoviesDataSourceInterface { 40 | return MoviesDataSource(dataTransferService: dependencies.apiDataTransferService) 41 | } 42 | func makeMoviesQueriesDataSource() -> MoviesQueriesDataSourceInterface { 43 | return MoviesQueriesDataSource(dataTransferService: dependencies.apiDataTransferService, 44 | moviesQueriesPersistentStorage: moviesQueriesStorage) 45 | } 46 | func makePosterImagesDataSource() -> PosterImagesDataSourceInterface { 47 | return PosterImagesDataSource(dataTransferService: dependencies.imageDataTransferService, 48 | imageNotFoundData: UIImage(named: "image_not_found")?.pngData()) 49 | } 50 | } 51 | 52 | extension MoviesSceneDIContainer: MoviesListViewCoordinatorDependencies, MoviesQueriesListCoordinatorDependencies {} 53 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/MVVM/MVVMView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MVVMView.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 09.04.19. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | protocol MVVMView { 12 | 13 | associatedtype ViewModel: MVVMViewModel 14 | 15 | var viewModel: ViewModel! { get set } 16 | 17 | func bind(to viewModel: ViewModel) 18 | } 19 | 20 | extension MVVMView where Self: UIViewController, Self: StoryboardInstantiable { 21 | 22 | static func instantiate(with viewModel: ViewModel) -> Self { 23 | 24 | var viewController = Self.instantiateViewController() 25 | viewController.viewModel = viewModel 26 | 27 | return viewController 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/MVVM/MVVMViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MVVMViewModel.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 09.04.19. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol MVVMViewModel { 11 | 12 | func viewDidLoad() 13 | } 14 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Observable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Box.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 16.02.19. 6 | // 7 | 8 | import Foundation 9 | 10 | class Observer { 11 | 12 | typealias ObserverBlock = (Value) -> Void 13 | 14 | weak var observer: AnyObject? 15 | let block: ObserverBlock 16 | let queue: DispatchQueue 17 | 18 | init(observer: AnyObject, queue: DispatchQueue, block: @escaping ObserverBlock) { 19 | self.observer = observer 20 | self.queue = queue 21 | self.block = block 22 | } 23 | } 24 | 25 | public class Observable { 26 | 27 | private var observers = [Observer]() 28 | 29 | public var value : Value { 30 | didSet { 31 | self.notifyObservers() 32 | } 33 | } 34 | 35 | public init(_ value: Value) { 36 | self.value = value 37 | } 38 | 39 | func observe(on observer: AnyObject, queue: DispatchQueue = .main, observerBlock: @escaping Observer.ObserverBlock) { 40 | self.observers.append(Observer(observer: observer, queue: queue, block: observerBlock)) 41 | } 42 | 43 | func observeAndFire(on observer: AnyObject, queue: DispatchQueue = .main, observerBlock: @escaping Observer.ObserverBlock) { 44 | self.observers.append(Observer(observer: observer, queue: queue, block: observerBlock)) 45 | observerBlock(value) 46 | } 47 | 48 | func remove(observer: AnyObject) { 49 | self.observers = self.observers.filter({ $0.observer !== observer }) 50 | } 51 | 52 | private func notifyObservers() { 53 | for observer in self.observers { 54 | observer.queue.async { [weak self] in 55 | guard let weakSelf = self else { return } 56 | observer.block(weakSelf.value) 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Protocols/Alertable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Alertable.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | 11 | protocol Alertable {} 12 | extension Alertable where Self: UIViewController { 13 | func showAlert(title: String = "", message: String, preferredStyle: UIAlertController.Style = .alert, completion: (() -> Void)? = nil) { 14 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 15 | alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: UIAlertAction.Style.default, handler: nil)) 16 | self.present(alert, animated: true, completion: completion) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Protocols/Coordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Coordinator.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.03.19. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol Coordinator: AnyObject { 11 | 12 | var childCoordinators: [Coordinator] { get set } 13 | 14 | func start() 15 | } 16 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Protocols/StoryboardInstantiable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoryboardInstantiable.swift 3 | // ExampleMVVM 4 | // 5 | // Created by Oleh Kudinov on 03.03.19. 6 | // 7 | 8 | import UIKit 9 | 10 | protocol StoryboardInstantiable: NSObjectProtocol { 11 | associatedtype T 12 | static var defaultFileName: String { get } 13 | static func instantiateViewController(_ bundle: Bundle?) -> T 14 | } 15 | 16 | extension StoryboardInstantiable where Self: UIViewController { 17 | static var defaultFileName: String { 18 | return NSStringFromClass(Self.self).components(separatedBy: ".").last! 19 | } 20 | 21 | static func instantiateViewController(_ bundle: Bundle? = nil) -> Self { 22 | let fileName = NSStringFromClass(Self.self).components(separatedBy: ".").last! 23 | let storyboard = UIStoryboard(name: fileName, bundle: bundle) 24 | return storyboard.instantiateInitialViewController() as! Self 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ExampleMVVM/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /ExampleMVVM/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ExampleMVVM/Resources/Assets.xcassets/image_not_found.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "image_not_found.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /ExampleMVVM/Resources/Assets.xcassets/image_not_found.imageset/image_not_found.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Example-MVVM-C/1aaa3b2925683a033a609d6e21fd73bc9c05091d/ExampleMVVM/Resources/Assets.xcassets/image_not_found.imageset/image_not_found.png -------------------------------------------------------------------------------- /ExampleMVVM/Resources/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /ExampleMVVM/Resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ApiBaseURL 6 | $(API_BASE_URL) 7 | ApiKey 8 | $(API_KEY) 9 | CFBundleDevelopmentRegion 10 | $(DEVELOPMENT_LANGUAGE) 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIdentifier 14 | $(PRODUCT_BUNDLE_IDENTIFIER) 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | $(PRODUCT_NAME) 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 1.0 23 | CFBundleVersion 24 | 1 25 | ImageBaseURL 26 | $(IMAGE_BASE_URL) 27 | LSRequiresIPhoneOS 28 | 29 | NSAppTransportSecurity 30 | 31 | NSAllowsArbitraryLoads 32 | 33 | NSExceptionDomains 34 | 35 | api.themoviedb.org 36 | 37 | NSExceptionAllowsInsecureHTTPLoads 38 | 39 | NSIncludesSubdomains 40 | 41 | 42 | image.tmdb.org 43 | 44 | NSExceptionAllowsInsecureHTTPLoads 45 | 46 | NSIncludesSubdomains 47 | 48 | 49 | 50 | 51 | UILaunchStoryboardName 52 | LaunchScreen 53 | UIRequiredDeviceCapabilities 54 | 55 | armv7 56 | 57 | UIStatusBarStyle 58 | UIStatusBarStyleLightContent 59 | UISupportedInterfaceOrientations 60 | 61 | UIInterfaceOrientationPortrait 62 | UIInterfaceOrientationLandscapeLeft 63 | UIInterfaceOrientationLandscapeRight 64 | 65 | UISupportedInterfaceOrientations~ipad 66 | 67 | UIInterfaceOrientationPortrait 68 | UIInterfaceOrientationPortraitUpsideDown 69 | UIInterfaceOrientationLandscapeLeft 70 | UIInterfaceOrientationLandscapeRight 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /ExampleMVVMTests/FetchMoviesUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchMoviesUseCaseTests.swift 3 | // CodeChallengeTests 4 | // 5 | // Created by Oleh Kudinov on 01.10.18. 6 | // 7 | 8 | import XCTest 9 | 10 | class FetchMoviesUseCaseTests: XCTestCase { 11 | 12 | static var moviesPages: [MoviesPage] { 13 | let page1 = MoviesPage(page: 1, totalPages: 2, movies: [ 14 | Movie(id: 1, title: "title1", posterPath: "/1", overview: "overview1", releaseDate: nil), 15 | Movie(id: 2, title: "title2", posterPath: "/2", overview: "overview2", releaseDate: nil)]) 16 | let page2 = MoviesPage(page: 2, totalPages: 2, movies: [ 17 | Movie(id: 3, title: "title3", posterPath: "/3", overview: "overview3", releaseDate: nil)]) 18 | return [page1, page2] 19 | } 20 | 21 | enum MoviesDataSourceSuccessTestError: Error { 22 | case failedFetching 23 | } 24 | 25 | class MoviesQueriesDataSourceMock: MoviesQueriesDataSourceInterface { 26 | var recentQueries: [MovieQuery] = [] 27 | func recentsQueries(number: Int) -> [MovieQuery] { 28 | return recentQueries 29 | } 30 | func saveRecentQuery(query: MovieQuery) { 31 | recentQueries.append(query) 32 | } 33 | } 34 | 35 | class MoviesDataSourceSuccessMock: MoviesDataSourceInterface { 36 | func moviesList(query: MovieQuery, page: Int, with result: @escaping (Result) -> Void) -> Cancellable? { 37 | result(.success(FetchMoviesUseCaseTests.moviesPages[0])) 38 | return nil 39 | } 40 | } 41 | 42 | class MoviesDataSourceFailureMock: MoviesDataSourceInterface { 43 | func moviesList(query: MovieQuery, page: Int, with result: @escaping (Result) -> Void) -> Cancellable? { 44 | result(.failure(MoviesDataSourceSuccessTestError.failedFetching)) 45 | return nil 46 | } 47 | } 48 | 49 | override func setUp() { 50 | super.setUp() 51 | // Put setup code here. This method is called before the invocation of each test method in the class. 52 | } 53 | 54 | override func tearDown() { 55 | // Put teardown code here. This method is called after the invocation of each test method in the class. 56 | super.tearDown() 57 | } 58 | 59 | func testFetchMoviesUseCase_whenSuccessfullyFetchesMoviesForQuery_thenQueryIsSavedInRecentQueries() { 60 | // given 61 | let expectation = self.expectation(description: "Recent query saved") 62 | let moviesQueriesDataSource = MoviesQueriesDataSourceMock() 63 | let useCase = FetchMoviesUseCase(moviesDataSource: MoviesDataSourceSuccessMock(), 64 | moviesQueriesDataSource: moviesQueriesDataSource) 65 | 66 | // when 67 | let requestValue = FetchMoviesUseCaseRequestValue(query: MovieQuery(query: "title1"), 68 | page: 0) 69 | _ = useCase.execute(requestValue: requestValue) { movies in 70 | expectation.fulfill() 71 | } 72 | // then 73 | waitForExpectations(timeout: 5, handler: nil) 74 | XCTAssertTrue(moviesQueriesDataSource.recentsQueries(number: 1).contains(MovieQuery(query: "title1"))) 75 | } 76 | 77 | func testFetchMoviesUseCase_whenFailedFetchingMoviesForQuery_thenQueryIsNotSavedInRecentQueries() { 78 | // given 79 | let expectation = self.expectation(description: "Recent query saved") 80 | let moviesQueriesDataSource = MoviesQueriesDataSourceMock() 81 | let useCase = FetchMoviesUseCase(moviesDataSource: MoviesDataSourceFailureMock(), 82 | moviesQueriesDataSource: moviesQueriesDataSource) 83 | 84 | // when 85 | let requestValue = FetchMoviesUseCaseRequestValue(query: MovieQuery(query: "title1"), 86 | page: 0) 87 | _ = useCase.execute(requestValue: requestValue) { movies in 88 | expectation.fulfill() 89 | } 90 | // then 91 | waitForExpectations(timeout: 5, handler: nil) 92 | XCTAssertTrue(moviesQueriesDataSource.recentsQueries(number: 1).isEmpty) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # iOS-Example-MVVM-C 2 | 3 | iOS Project Example using MVVM and Coordinator. Raywenderlich Advanced iOS Architecture 4 | 5 | To search a movie, write a name of a movie inside searchbar input and hit enter or search button. There are two distinct network calls (request movies and request poster images) Every successful search query is stored persistently. There are two concurrent requests: Fetch movies and fetch movie offer. And waiting for both of them to finish loading of screen. 6 | 7 | Requirements: Xcode Version 10.2.1 with Swift 5.0 8 | --------------------------------------------------------------------------------