├── .gitignore ├── .travis.yml ├── ExampleMVVM.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ └── oleh.kudinov.xcuserdatad │ │ └── IDEFindNavigatorScopes.plist ├── xcshareddata │ └── xcschemes │ │ └── ExampleMVVM.xcscheme └── xcuserdata │ └── oleh.kudinov.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── ExampleMVVM ├── Application │ ├── AppAppearance.swift │ ├── AppConfigurations.swift │ ├── AppDelegate.swift │ ├── AppFlowCoordinator.swift │ └── DIContainer │ │ ├── AppDIContainer.swift │ │ └── MoviesSceneDIContainer.swift ├── Common │ ├── Cancellable.swift │ ├── ConnectionError.swift │ └── DispatchQueueType.swift ├── Data │ ├── Network │ │ ├── APIEndpoints.swift │ │ └── DataMapping │ │ │ ├── MoviesRequestDTO+Mapping.swift │ │ │ └── MoviesResponseDTO+Mapping.swift │ ├── PersistentStorages │ │ ├── CoreDataStorage │ │ │ ├── CoreDataStorage.swift │ │ │ └── CoreDataStorage.xcdatamodeld │ │ │ │ ├── .xccurrentversion │ │ │ │ ├── CoreDataStorage 2.xcdatamodel │ │ │ │ └── contents │ │ │ │ └── CoreDataStorage.xcdatamodel │ │ │ │ └── contents │ │ ├── MoviesQueriesStorage │ │ │ ├── CoreDataStorage │ │ │ │ ├── CoreDataMoviesQueriesStorage.swift │ │ │ │ └── EntityMapping │ │ │ │ │ └── MovieQueryEntity+Mapping.swift │ │ │ ├── MoviesQueriesStorage.swift │ │ │ └── UserDefaultsStorage │ │ │ │ ├── DataMapping │ │ │ │ └── MovieQueryUDS+Mapping.swift │ │ │ │ └── UserDefaultsMoviesQueriesStorage.swift │ │ └── MoviesResponseStorage │ │ │ ├── CoreDataMoviesResponseStorage.swift │ │ │ ├── EntityMapping │ │ │ └── MoviesResponseEntity+Mapping.swift │ │ │ └── MoviesResponseStorage.swift │ └── Repositories │ │ ├── DefaultMoviesQueriesRepository.swift │ │ ├── DefaultMoviesRepository.swift │ │ ├── DefaultPosterImagesRepository.swift │ │ └── Utils │ │ └── RepositoryTask.swift ├── Domain │ ├── Entities │ │ ├── Movie.swift │ │ └── MovieQuery.swift │ ├── Interfaces │ │ └── Repositories │ │ │ ├── MoviesQueriesRepository.swift │ │ │ ├── MoviesRepository.swift │ │ │ └── PosterImagesRepository.swift │ └── UseCases │ │ ├── FetchRecentMovieQueriesUseCase.swift │ │ ├── Protocol │ │ └── UseCase.swift │ │ └── SearchMoviesUseCase.swift ├── Infrastructure │ └── Network │ │ ├── DataTransferService.swift │ │ ├── Endpoint.swift │ │ ├── NetworkConfig.swift │ │ └── NetworkService.swift ├── Mocks │ └── DispatchQueueTypeMock.swift ├── Presentation │ ├── MoviesScene │ │ ├── Behaviors │ │ │ ├── BackButtonEmptyTitleNavigationBarBehavior.swift │ │ │ └── BlackStyleNavigationBarBehavior.swift │ │ ├── Flows │ │ │ └── MoviesSearchFlowCoordinator.swift │ │ ├── MovieDetails │ │ │ ├── View │ │ │ │ ├── MovieDetailsViewController.storyboard │ │ │ │ └── MovieDetailsViewController.swift │ │ │ └── ViewModel │ │ │ │ └── MovieDetailsViewModel.swift │ │ ├── MoviesList │ │ │ ├── View │ │ │ │ ├── MoviesListTableView │ │ │ │ │ ├── Cells │ │ │ │ │ │ └── MoviesListItemCell.swift │ │ │ │ │ └── MoviesListTableViewController.swift │ │ │ │ ├── MoviesListViewController.storyboard │ │ │ │ └── MoviesListViewController.swift │ │ │ └── ViewModel │ │ │ │ ├── MoviesListItemViewModel.swift │ │ │ │ └── MoviesListViewModel.swift │ │ └── MoviesQueriesList │ │ │ ├── View │ │ │ ├── SwiftUI │ │ │ │ └── MoviesQueryListView.swift │ │ │ └── UIKit │ │ │ │ ├── Cells │ │ │ │ └── MoviesQueriesItemCell.swift │ │ │ │ ├── MoviesQueriesTableViewController.storyboard │ │ │ │ └── MoviesQueriesTableViewController.swift │ │ │ └── ViewModel │ │ │ ├── MoviesQueryListItemViewModel.swift │ │ │ └── MoviesQueryListViewModel.swift │ └── Utils │ │ ├── AccessibilityIdentifier.swift │ │ ├── Extensions │ │ ├── CGSize+ScaledSize.swift │ │ ├── DataTransferError+ConnectionError.swift │ │ ├── UIImageView+ImageSizeAfterAspectFit.swift │ │ ├── UIViewController+ActivityIndicator.swift │ │ ├── UIViewController+AddBehaviors.swift │ │ └── UIViewController+AddChild.swift │ │ ├── LoadingView.swift │ │ ├── Observable.swift │ │ └── Protocols │ │ ├── Alertable.swift │ │ └── StoryboardInstantiable.swift ├── Resources │ ├── Assets.xcassets │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Info.plist │ ├── en.lproj │ │ └── Localizable.strings │ └── es.lproj │ │ ├── LaunchScreen.strings │ │ └── Localizable.strings └── Stubs │ └── Movie+Stub.swift ├── ExampleMVVMTests ├── Domain │ └── UseCases │ │ └── SearchMoviesUseCaseTests.swift ├── Info.plist ├── Infrastructure │ └── Network │ │ ├── DataTransferServiceTests.swift │ │ ├── Mocks │ │ ├── NetworkConfigurableMock.swift │ │ └── NetworkSessionManagerMock.swift │ │ └── NetworkServiceTests.swift └── Presentation │ └── MoviesScene │ ├── Mocks │ └── PosterImagesRepositoryMock.swift │ ├── MovieDetailsViewModelTests.swift │ ├── MoviesListViewModelTests.swift │ └── MoviesQueriesListViewModelTests.swift ├── ExampleMVVMUITests ├── Info.plist └── Presentation │ └── MoviesScene │ └── MoviesSceneUITests.swift ├── Gemfile ├── Gemfile.lock ├── MVVM Local Swift Packages.zip ├── MVVM Modular Layers Pods.zip ├── MVVM Templates ├── MVVM │ └── MVVM.xctemplate │ │ ├── TemplateInfo.plist │ │ ├── ___VARIABLE_sceneIdentifier___ViewController.storyboard │ │ ├── ___VARIABLE_sceneIdentifier___ViewController.swift │ │ └── ___VARIABLE_sceneIdentifier___ViewModel.swift ├── MVVMR │ └── MVVMR.xctemplate │ │ ├── TemplateInfo.plist │ │ ├── ___VARIABLE_sceneIdentifier___ViewController.storyboard │ │ ├── ___VARIABLE_sceneIdentifier___ViewController.swift │ │ └── ___VARIABLE_sceneIdentifier___ViewModel.swift └── README.md ├── README.md └── README_FILES ├── CleanArchitecture+MVVM.png └── CleanArchitectureDependencies.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OS X 2 | *.DS_Store 3 | 4 | # Xcode 5 | *.xcuserstate 6 | project.xcworkspace/ 7 | xcuserdata/ 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: osx 2 | osx_image: xcode11.2 3 | language: swift 4 | script: 5 | - fastlane scan --scheme ExampleMVVM --device "iPhone 11 Pro Max" 6 | -------------------------------------------------------------------------------- /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/xcshareddata/xcschemes/ExampleMVVM.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 59 | 60 | 61 | 62 | 63 | 64 | 74 | 76 | 82 | 83 | 84 | 85 | 86 | 87 | 93 | 95 | 101 | 102 | 103 | 104 | 106 | 107 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /ExampleMVVM.xcodeproj/xcuserdata/oleh.kudinov.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /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 | import Foundation 2 | import UIKit 3 | 4 | final class AppAppearance { 5 | 6 | static func setupAppearance() { 7 | if #available(iOS 15, *) { 8 | let appearance = UINavigationBarAppearance() 9 | appearance.configureWithOpaqueBackground() 10 | appearance.titleTextAttributes = [.foregroundColor: UIColor.white] 11 | appearance.backgroundColor = UIColor(red: 37/255.0, green: 37/255.0, blue: 37.0/255.0, alpha: 1.0) 12 | UINavigationBar.appearance().standardAppearance = appearance 13 | UINavigationBar.appearance().scrollEdgeAppearance = appearance 14 | } else { 15 | UINavigationBar.appearance().barTintColor = .black 16 | UINavigationBar.appearance().tintColor = .white 17 | UINavigationBar.appearance().titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white] 18 | } 19 | } 20 | } 21 | 22 | extension UINavigationController { 23 | @objc override open var preferredStatusBarStyle: UIStatusBarStyle { 24 | return .lightContent 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/AppConfigurations.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class AppConfiguration { 4 | lazy var apiKey: String = { 5 | guard let apiKey = Bundle.main.object(forInfoDictionaryKey: "ApiKey") as? String else { 6 | fatalError("ApiKey must not be empty in plist") 7 | } 8 | return apiKey 9 | }() 10 | lazy var apiBaseURL: String = { 11 | guard let apiBaseURL = Bundle.main.object(forInfoDictionaryKey: "ApiBaseURL") as? String else { 12 | fatalError("ApiBaseURL must not be empty in plist") 13 | } 14 | return apiBaseURL 15 | }() 16 | lazy var imagesBaseURL: String = { 17 | guard let imageBaseURL = Bundle.main.object(forInfoDictionaryKey: "ImageBaseURL") as? String else { 18 | fatalError("ApiBaseURL must not be empty in plist") 19 | } 20 | return imageBaseURL 21 | }() 22 | } 23 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | 6 | let appDIContainer = AppDIContainer() 7 | var appFlowCoordinator: AppFlowCoordinator? 8 | var window: UIWindow? 9 | 10 | func application( 11 | _ application: UIApplication, 12 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 13 | ) -> Bool { 14 | 15 | AppAppearance.setupAppearance() 16 | 17 | window = UIWindow(frame: UIScreen.main.bounds) 18 | let navigationController = UINavigationController() 19 | 20 | window?.rootViewController = navigationController 21 | appFlowCoordinator = AppFlowCoordinator( 22 | navigationController: navigationController, 23 | appDIContainer: appDIContainer 24 | ) 25 | appFlowCoordinator?.start() 26 | window?.makeKeyAndVisible() 27 | 28 | return true 29 | } 30 | 31 | func applicationDidEnterBackground(_ application: UIApplication) { 32 | CoreDataStorage.shared.saveContext() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/AppFlowCoordinator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class AppFlowCoordinator { 4 | 5 | var navigationController: UINavigationController 6 | private let appDIContainer: AppDIContainer 7 | 8 | init( 9 | navigationController: UINavigationController, 10 | appDIContainer: AppDIContainer 11 | ) { 12 | self.navigationController = navigationController 13 | self.appDIContainer = appDIContainer 14 | } 15 | 16 | func start() { 17 | // In App Flow we can check if user needs to login, if yes we would run login flow 18 | let moviesSceneDIContainer = appDIContainer.makeMoviesSceneDIContainer() 19 | let flow = moviesSceneDIContainer.makeMoviesSearchFlowCoordinator(navigationController: navigationController) 20 | flow.start() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/DIContainer/AppDIContainer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class AppDIContainer { 4 | 5 | lazy var appConfiguration = AppConfiguration() 6 | 7 | // MARK: - Network 8 | lazy var apiDataTransferService: DataTransferService = { 9 | let config = ApiDataNetworkConfig( 10 | baseURL: URL(string: appConfiguration.apiBaseURL)!, 11 | queryParameters: [ 12 | "api_key": appConfiguration.apiKey, 13 | "language": NSLocale.preferredLanguages.first ?? "en" 14 | ] 15 | ) 16 | 17 | let apiDataNetwork = DefaultNetworkService(config: config) 18 | return DefaultDataTransferService(with: apiDataNetwork) 19 | }() 20 | lazy var imageDataTransferService: DataTransferService = { 21 | let config = ApiDataNetworkConfig( 22 | baseURL: URL(string: appConfiguration.imagesBaseURL)! 23 | ) 24 | let imagesDataNetwork = DefaultNetworkService(config: config) 25 | return DefaultDataTransferService(with: imagesDataNetwork) 26 | }() 27 | 28 | // MARK: - DIContainers of scenes 29 | func makeMoviesSceneDIContainer() -> MoviesSceneDIContainer { 30 | let dependencies = MoviesSceneDIContainer.Dependencies( 31 | apiDataTransferService: apiDataTransferService, 32 | imageDataTransferService: imageDataTransferService 33 | ) 34 | return MoviesSceneDIContainer(dependencies: dependencies) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /ExampleMVVM/Application/DIContainer/MoviesSceneDIContainer.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | 4 | final class MoviesSceneDIContainer: MoviesSearchFlowCoordinatorDependencies { 5 | 6 | struct Dependencies { 7 | let apiDataTransferService: DataTransferService 8 | let imageDataTransferService: DataTransferService 9 | } 10 | 11 | private let dependencies: Dependencies 12 | 13 | // MARK: - Persistent Storage 14 | lazy var moviesQueriesStorage: MoviesQueriesStorage = CoreDataMoviesQueriesStorage(maxStorageLimit: 10) 15 | lazy var moviesResponseCache: MoviesResponseStorage = CoreDataMoviesResponseStorage() 16 | 17 | init(dependencies: Dependencies) { 18 | self.dependencies = dependencies 19 | } 20 | 21 | // MARK: - Use Cases 22 | func makeSearchMoviesUseCase() -> SearchMoviesUseCase { 23 | DefaultSearchMoviesUseCase( 24 | moviesRepository: makeMoviesRepository(), 25 | moviesQueriesRepository: makeMoviesQueriesRepository() 26 | ) 27 | } 28 | 29 | func makeFetchRecentMovieQueriesUseCase( 30 | requestValue: FetchRecentMovieQueriesUseCase.RequestValue, 31 | completion: @escaping (FetchRecentMovieQueriesUseCase.ResultValue) -> Void 32 | ) -> UseCase { 33 | FetchRecentMovieQueriesUseCase( 34 | requestValue: requestValue, 35 | completion: completion, 36 | moviesQueriesRepository: makeMoviesQueriesRepository() 37 | ) 38 | } 39 | 40 | // MARK: - Repositories 41 | func makeMoviesRepository() -> MoviesRepository { 42 | DefaultMoviesRepository( 43 | dataTransferService: dependencies.apiDataTransferService, 44 | cache: moviesResponseCache 45 | ) 46 | } 47 | func makeMoviesQueriesRepository() -> MoviesQueriesRepository { 48 | DefaultMoviesQueriesRepository( 49 | moviesQueriesPersistentStorage: moviesQueriesStorage 50 | ) 51 | } 52 | func makePosterImagesRepository() -> PosterImagesRepository { 53 | DefaultPosterImagesRepository( 54 | dataTransferService: dependencies.imageDataTransferService 55 | ) 56 | } 57 | 58 | // MARK: - Movies List 59 | func makeMoviesListViewController(actions: MoviesListViewModelActions) -> MoviesListViewController { 60 | MoviesListViewController.create( 61 | with: makeMoviesListViewModel(actions: actions), 62 | posterImagesRepository: makePosterImagesRepository() 63 | ) 64 | } 65 | 66 | func makeMoviesListViewModel(actions: MoviesListViewModelActions) -> MoviesListViewModel { 67 | DefaultMoviesListViewModel( 68 | searchMoviesUseCase: makeSearchMoviesUseCase(), 69 | actions: actions 70 | ) 71 | } 72 | 73 | // MARK: - Movie Details 74 | func makeMoviesDetailsViewController(movie: Movie) -> UIViewController { 75 | MovieDetailsViewController.create( 76 | with: makeMoviesDetailsViewModel(movie: movie) 77 | ) 78 | } 79 | 80 | func makeMoviesDetailsViewModel(movie: Movie) -> MovieDetailsViewModel { 81 | DefaultMovieDetailsViewModel( 82 | movie: movie, 83 | posterImagesRepository: makePosterImagesRepository() 84 | ) 85 | } 86 | 87 | // MARK: - Movies Queries Suggestions List 88 | func makeMoviesQueriesSuggestionsListViewController(didSelect: @escaping MoviesQueryListViewModelDidSelectAction) -> UIViewController { 89 | if #available(iOS 13.0, *) { // SwiftUI 90 | let view = MoviesQueryListView( 91 | viewModelWrapper: makeMoviesQueryListViewModelWrapper(didSelect: didSelect) 92 | ) 93 | return UIHostingController(rootView: view) 94 | } else { // UIKit 95 | return MoviesQueriesTableViewController.create( 96 | with: makeMoviesQueryListViewModel(didSelect: didSelect) 97 | ) 98 | } 99 | } 100 | 101 | func makeMoviesQueryListViewModel(didSelect: @escaping MoviesQueryListViewModelDidSelectAction) -> MoviesQueryListViewModel { 102 | DefaultMoviesQueryListViewModel( 103 | numberOfQueriesToShow: 10, 104 | fetchRecentMovieQueriesUseCaseFactory: makeFetchRecentMovieQueriesUseCase, 105 | didSelect: didSelect 106 | ) 107 | } 108 | 109 | @available(iOS 13.0, *) 110 | func makeMoviesQueryListViewModelWrapper( 111 | didSelect: @escaping MoviesQueryListViewModelDidSelectAction 112 | ) -> MoviesQueryListViewModelWrapper { 113 | MoviesQueryListViewModelWrapper( 114 | viewModel: makeMoviesQueryListViewModel(didSelect: didSelect) 115 | ) 116 | } 117 | 118 | // MARK: - Flow Coordinators 119 | func makeMoviesSearchFlowCoordinator(navigationController: UINavigationController) -> MoviesSearchFlowCoordinator { 120 | MoviesSearchFlowCoordinator( 121 | navigationController: navigationController, 122 | dependencies: self 123 | ) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /ExampleMVVM/Common/Cancellable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol Cancellable { 4 | func cancel() 5 | } 6 | -------------------------------------------------------------------------------- /ExampleMVVM/Common/ConnectionError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol ConnectionError: Error { 4 | var isInternetConnectionError: Bool { get } 5 | } 6 | 7 | extension Error { 8 | var isInternetConnectionError: Bool { 9 | guard let error = self as? ConnectionError, error.isInternetConnectionError else { 10 | return false 11 | } 12 | return true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ExampleMVVM/Common/DispatchQueueType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Used to easily mock main and background queues in tests 4 | protocol DispatchQueueType { 5 | func async(execute work: @escaping () -> Void) 6 | } 7 | 8 | extension DispatchQueue: DispatchQueueType { 9 | func async(execute work: @escaping () -> Void) { 10 | async(group: nil, execute: work) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Network/APIEndpoints.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct APIEndpoints { 4 | 5 | static func getMovies(with moviesRequestDTO: MoviesRequestDTO) -> Endpoint { 6 | 7 | return Endpoint( 8 | path: "3/search/movie", 9 | method: .get, 10 | queryParametersEncodable: moviesRequestDTO 11 | ) 12 | } 13 | 14 | static func getMoviePoster(path: String, width: Int) -> Endpoint { 15 | 16 | let sizes = [92, 154, 185, 342, 500, 780] 17 | let closestWidth = sizes 18 | .enumerated() 19 | .min { abs($0.1 - width) < abs($1.1 - width) }? 20 | .element ?? sizes.first! 21 | 22 | return Endpoint( 23 | path: "t/p/w\(closestWidth)\(path)", 24 | method: .get, 25 | responseDecoder: RawDataResponseDecoder() 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Network/DataMapping/MoviesRequestDTO+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MoviesRequestDTO: Encodable { 4 | let query: String 5 | let page: Int 6 | } 7 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Network/DataMapping/MoviesResponseDTO+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - Data Transfer Object 4 | 5 | struct MoviesResponseDTO: Decodable { 6 | private enum CodingKeys: String, CodingKey { 7 | case page 8 | case totalPages = "total_pages" 9 | case movies = "results" 10 | } 11 | let page: Int 12 | let totalPages: Int 13 | let movies: [MovieDTO] 14 | } 15 | 16 | extension MoviesResponseDTO { 17 | struct MovieDTO: Decodable { 18 | private enum CodingKeys: String, CodingKey { 19 | case id 20 | case title 21 | case genre 22 | case posterPath = "poster_path" 23 | case overview 24 | case releaseDate = "release_date" 25 | } 26 | enum GenreDTO: String, Decodable { 27 | case adventure 28 | case scienceFiction = "science_fiction" 29 | } 30 | let id: Int 31 | let title: String? 32 | let genre: GenreDTO? 33 | let posterPath: String? 34 | let overview: String? 35 | let releaseDate: String? 36 | } 37 | } 38 | 39 | // MARK: - Mappings to Domain 40 | 41 | extension MoviesResponseDTO { 42 | func toDomain() -> MoviesPage { 43 | return .init(page: page, 44 | totalPages: totalPages, 45 | movies: movies.map { $0.toDomain() }) 46 | } 47 | } 48 | 49 | extension MoviesResponseDTO.MovieDTO { 50 | func toDomain() -> Movie { 51 | return .init(id: Movie.Identifier(id), 52 | title: title, 53 | genre: genre?.toDomain(), 54 | posterPath: posterPath, 55 | overview: overview, 56 | releaseDate: dateFormatter.date(from: releaseDate ?? "")) 57 | } 58 | } 59 | 60 | extension MoviesResponseDTO.MovieDTO.GenreDTO { 61 | func toDomain() -> Movie.Genre { 62 | switch self { 63 | case .adventure: return .adventure 64 | case .scienceFiction: return .scienceFiction 65 | } 66 | } 67 | } 68 | 69 | // MARK: - Private 70 | 71 | private let dateFormatter: DateFormatter = { 72 | let formatter = DateFormatter() 73 | formatter.dateFormat = "yyyy-MM-dd" 74 | formatter.calendar = Calendar(identifier: .iso8601) 75 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 76 | formatter.locale = Locale(identifier: "en_US_POSIX") 77 | return formatter 78 | }() 79 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/CoreDataStorage/CoreDataStorage.swift: -------------------------------------------------------------------------------- 1 | import CoreData 2 | 3 | enum CoreDataStorageError: Error { 4 | case readError(Error) 5 | case saveError(Error) 6 | case deleteError(Error) 7 | } 8 | 9 | final class CoreDataStorage { 10 | 11 | static let shared = CoreDataStorage() 12 | 13 | // MARK: - Core Data stack 14 | private lazy var persistentContainer: NSPersistentContainer = { 15 | let container = NSPersistentContainer(name: "CoreDataStorage") 16 | container.loadPersistentStores { _, error in 17 | if let error = error as NSError? { 18 | // TODO: - Log to Crashlytics 19 | assertionFailure("CoreDataStorage Unresolved error \(error), \(error.userInfo)") 20 | } 21 | } 22 | return container 23 | }() 24 | 25 | // MARK: - Core Data Saving support 26 | func saveContext() { 27 | let context = persistentContainer.viewContext 28 | if context.hasChanges { 29 | do { 30 | try context.save() 31 | } catch { 32 | // TODO: - Log to Crashlytics 33 | assertionFailure("CoreDataStorage Unresolved error \(error), \((error as NSError).userInfo)") 34 | } 35 | } 36 | } 37 | 38 | func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void) { 39 | persistentContainer.performBackgroundTask(block) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/CoreDataStorage/CoreDataStorage.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | CoreDataStorage 2.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/CoreDataStorage/CoreDataStorage.xcdatamodeld/CoreDataStorage 2.xcdatamodel/contents: -------------------------------------------------------------------------------- 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 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/CoreDataStorage/CoreDataStorage.xcdatamodeld/CoreDataStorage.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/MoviesQueriesStorage/CoreDataStorage/CoreDataMoviesQueriesStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | final class CoreDataMoviesQueriesStorage { 5 | 6 | private let maxStorageLimit: Int 7 | private let coreDataStorage: CoreDataStorage 8 | 9 | init( 10 | maxStorageLimit: Int, 11 | coreDataStorage: CoreDataStorage = CoreDataStorage.shared 12 | ) { 13 | self.maxStorageLimit = maxStorageLimit 14 | self.coreDataStorage = coreDataStorage 15 | } 16 | } 17 | 18 | extension CoreDataMoviesQueriesStorage: MoviesQueriesStorage { 19 | 20 | func fetchRecentsQueries( 21 | maxCount: Int, 22 | completion: @escaping (Result<[MovieQuery], Error>) -> Void 23 | ) { 24 | 25 | coreDataStorage.performBackgroundTask { context in 26 | do { 27 | let request: NSFetchRequest = MovieQueryEntity.fetchRequest() 28 | request.sortDescriptors = [NSSortDescriptor(key: #keyPath(MovieQueryEntity.createdAt), 29 | ascending: false)] 30 | request.fetchLimit = maxCount 31 | let result = try context.fetch(request).map { $0.toDomain() } 32 | 33 | completion(.success(result)) 34 | } catch { 35 | completion(.failure(CoreDataStorageError.readError(error))) 36 | } 37 | } 38 | } 39 | 40 | func saveRecentQuery( 41 | query: MovieQuery, 42 | completion: @escaping (Result) -> Void 43 | ) { 44 | 45 | coreDataStorage.performBackgroundTask { [weak self] context in 46 | guard let self = self else { return } 47 | do { 48 | try self.cleanUpQueries(for: query, inContext: context) 49 | let entity = MovieQueryEntity(movieQuery: query, insertInto: context) 50 | try context.save() 51 | 52 | completion(.success(entity.toDomain())) 53 | } catch { 54 | completion(.failure(CoreDataStorageError.saveError(error))) 55 | } 56 | } 57 | } 58 | } 59 | 60 | // MARK: - Private 61 | extension CoreDataMoviesQueriesStorage { 62 | 63 | private func cleanUpQueries( 64 | for query: MovieQuery, 65 | inContext context: NSManagedObjectContext 66 | ) throws { 67 | let request: NSFetchRequest = MovieQueryEntity.fetchRequest() 68 | request.sortDescriptors = [NSSortDescriptor(key: #keyPath(MovieQueryEntity.createdAt), 69 | ascending: false)] 70 | var result = try context.fetch(request) 71 | 72 | removeDuplicates(for: query, in: &result, inContext: context) 73 | removeQueries(limit: maxStorageLimit - 1, in: result, inContext: context) 74 | } 75 | 76 | private func removeDuplicates( 77 | for query: MovieQuery, 78 | in queries: inout [MovieQueryEntity], 79 | inContext context: NSManagedObjectContext 80 | ) { 81 | queries 82 | .filter { $0.query == query.query } 83 | .forEach { context.delete($0) } 84 | queries.removeAll { $0.query == query.query } 85 | } 86 | 87 | private func removeQueries( 88 | limit: Int, 89 | in queries: [MovieQueryEntity], 90 | inContext context: NSManagedObjectContext 91 | ) { 92 | guard queries.count > limit else { return } 93 | 94 | queries.suffix(queries.count - limit) 95 | .forEach { context.delete($0) } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/MoviesQueriesStorage/CoreDataStorage/EntityMapping/MovieQueryEntity+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | extension MovieQueryEntity { 5 | convenience init(movieQuery: MovieQuery, insertInto context: NSManagedObjectContext) { 6 | self.init(context: context) 7 | query = movieQuery.query 8 | createdAt = Date() 9 | } 10 | } 11 | 12 | extension MovieQueryEntity { 13 | func toDomain() -> MovieQuery { 14 | return .init(query: query ?? "") 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/MoviesQueriesStorage/MoviesQueriesStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol MoviesQueriesStorage { 4 | func fetchRecentsQueries( 5 | maxCount: Int, 6 | completion: @escaping (Result<[MovieQuery], Error>) -> Void 7 | ) 8 | func saveRecentQuery( 9 | query: MovieQuery, 10 | completion: @escaping (Result) -> Void 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/MoviesQueriesStorage/UserDefaultsStorage/DataMapping/MovieQueryUDS+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MovieQueriesListUDS: Codable { 4 | var list: [MovieQueryUDS] 5 | } 6 | 7 | struct MovieQueryUDS: Codable { 8 | let query: String 9 | } 10 | 11 | extension MovieQueryUDS { 12 | init(movieQuery: MovieQuery) { 13 | query = movieQuery.query 14 | } 15 | } 16 | 17 | extension MovieQueryUDS { 18 | func toDomain() -> MovieQuery { 19 | return .init(query: query) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/MoviesQueriesStorage/UserDefaultsStorage/UserDefaultsMoviesQueriesStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class UserDefaultsMoviesQueriesStorage { 4 | private let maxStorageLimit: Int 5 | private let recentsMoviesQueriesKey = "recentsMoviesQueries" 6 | private var userDefaults: UserDefaults 7 | private let backgroundQueue: DispatchQueueType 8 | 9 | init( 10 | maxStorageLimit: Int, 11 | userDefaults: UserDefaults = UserDefaults.standard, 12 | backgroundQueue: DispatchQueueType = DispatchQueue.global(qos: .userInitiated) 13 | ) { 14 | self.maxStorageLimit = maxStorageLimit 15 | self.userDefaults = userDefaults 16 | self.backgroundQueue = backgroundQueue 17 | } 18 | 19 | private func fetchMoviesQueries() -> [MovieQuery] { 20 | if let queriesData = userDefaults.object(forKey: recentsMoviesQueriesKey) as? Data { 21 | if let movieQueryList = try? JSONDecoder().decode(MovieQueriesListUDS.self, from: queriesData) { 22 | return movieQueryList.list.map { $0.toDomain() } 23 | } 24 | } 25 | return [] 26 | } 27 | 28 | private func persist(moviesQueries: [MovieQuery]) { 29 | let encoder = JSONEncoder() 30 | let movieQueryUDSs = moviesQueries.map(MovieQueryUDS.init) 31 | if let encoded = try? encoder.encode(MovieQueriesListUDS(list: movieQueryUDSs)) { 32 | userDefaults.set(encoded, forKey: recentsMoviesQueriesKey) 33 | } 34 | } 35 | } 36 | 37 | extension UserDefaultsMoviesQueriesStorage: MoviesQueriesStorage { 38 | 39 | func fetchRecentsQueries( 40 | maxCount: Int, 41 | completion: @escaping (Result<[MovieQuery], Error>) -> Void 42 | ) { 43 | backgroundQueue.async { [weak self] in 44 | guard let self = self else { return } 45 | 46 | var queries = self.fetchMoviesQueries() 47 | queries = queries.count < self.maxStorageLimit ? queries : Array(queries[0..) -> Void 55 | ) { 56 | backgroundQueue.async { [weak self] in 57 | guard let self = self else { return } 58 | 59 | var queries = self.fetchMoviesQueries() 60 | self.cleanUpQueries(for: query, in: &queries) 61 | queries.insert(query, at: 0) 62 | self.persist(moviesQueries: queries) 63 | 64 | completion(.success(query)) 65 | } 66 | } 67 | } 68 | 69 | 70 | // MARK: - Private 71 | extension UserDefaultsMoviesQueriesStorage { 72 | 73 | private func cleanUpQueries(for query: MovieQuery, in queries: inout [MovieQuery]) { 74 | removeDuplicates(for: query, in: &queries) 75 | removeQueries(limit: maxStorageLimit - 1, in: &queries) 76 | } 77 | 78 | private func removeDuplicates(for query: MovieQuery, in queries: inout [MovieQuery]) { 79 | queries = queries.filter { $0 != query } 80 | } 81 | 82 | private func removeQueries(limit: Int, in queries: inout [MovieQuery]) { 83 | queries = queries.count <= limit ? queries : Array(queries[0.. NSFetchRequest { 17 | let request: NSFetchRequest = MoviesRequestEntity.fetchRequest() 18 | request.predicate = NSPredicate(format: "%K = %@ AND %K = %d", 19 | #keyPath(MoviesRequestEntity.query), requestDto.query, 20 | #keyPath(MoviesRequestEntity.page), requestDto.page) 21 | return request 22 | } 23 | 24 | private func deleteResponse( 25 | for requestDto: MoviesRequestDTO, 26 | in context: NSManagedObjectContext 27 | ) { 28 | let request = fetchRequest(for: requestDto) 29 | 30 | do { 31 | if let result = try context.fetch(request).first { 32 | context.delete(result) 33 | } 34 | } catch { 35 | print(error) 36 | } 37 | } 38 | } 39 | 40 | extension CoreDataMoviesResponseStorage: MoviesResponseStorage { 41 | 42 | func getResponse( 43 | for requestDto: MoviesRequestDTO, 44 | completion: @escaping (Result) -> Void 45 | ) { 46 | coreDataStorage.performBackgroundTask { context in 47 | do { 48 | let fetchRequest = self.fetchRequest(for: requestDto) 49 | let requestEntity = try context.fetch(fetchRequest).first 50 | 51 | completion(.success(requestEntity?.response?.toDTO())) 52 | } catch { 53 | completion(.failure(CoreDataStorageError.readError(error))) 54 | } 55 | } 56 | } 57 | 58 | func save( 59 | response responseDto: MoviesResponseDTO, 60 | for requestDto: MoviesRequestDTO 61 | ) { 62 | coreDataStorage.performBackgroundTask { context in 63 | do { 64 | self.deleteResponse(for: requestDto, in: context) 65 | 66 | let requestEntity = requestDto.toEntity(in: context) 67 | requestEntity.response = responseDto.toEntity(in: context) 68 | 69 | try context.save() 70 | } catch { 71 | // TODO: - Log to Crashlytics 72 | debugPrint("CoreDataMoviesResponseStorage Unresolved error \(error), \((error as NSError).userInfo)") 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/MoviesResponseStorage/EntityMapping/MoviesResponseEntity+Mapping.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreData 3 | 4 | extension MoviesResponseEntity { 5 | func toDTO() -> MoviesResponseDTO { 6 | return .init( 7 | page: Int(page), 8 | totalPages: Int(totalPages), 9 | movies: movies?.allObjects.map { ($0 as! MovieResponseEntity).toDTO() } ?? [] 10 | ) 11 | } 12 | } 13 | 14 | extension MovieResponseEntity { 15 | func toDTO() -> MoviesResponseDTO.MovieDTO { 16 | return .init( 17 | id: Int(id), 18 | title: title, 19 | genre: MoviesResponseDTO.MovieDTO.GenreDTO(rawValue: genre ?? ""), 20 | posterPath: posterPath, 21 | overview: overview, 22 | releaseDate: releaseDate 23 | ) 24 | } 25 | } 26 | 27 | extension MoviesRequestDTO { 28 | func toEntity(in context: NSManagedObjectContext) -> MoviesRequestEntity { 29 | let entity: MoviesRequestEntity = .init(context: context) 30 | entity.query = query 31 | entity.page = Int32(page) 32 | return entity 33 | } 34 | } 35 | 36 | extension MoviesResponseDTO { 37 | func toEntity(in context: NSManagedObjectContext) -> MoviesResponseEntity { 38 | let entity: MoviesResponseEntity = .init(context: context) 39 | entity.page = Int32(page) 40 | entity.totalPages = Int32(totalPages) 41 | movies.forEach { 42 | entity.addToMovies($0.toEntity(in: context)) 43 | } 44 | return entity 45 | } 46 | } 47 | 48 | extension MoviesResponseDTO.MovieDTO { 49 | func toEntity(in context: NSManagedObjectContext) -> MovieResponseEntity { 50 | let entity: MovieResponseEntity = .init(context: context) 51 | entity.id = Int64(id) 52 | entity.title = title 53 | entity.genre = genre?.rawValue 54 | entity.posterPath = posterPath 55 | entity.overview = overview 56 | entity.releaseDate = releaseDate 57 | return entity 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/PersistentStorages/MoviesResponseStorage/MoviesResponseStorage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol MoviesResponseStorage { 4 | func getResponse( 5 | for request: MoviesRequestDTO, 6 | completion: @escaping (Result) -> Void 7 | ) 8 | func save(response: MoviesResponseDTO, for requestDto: MoviesRequestDTO) 9 | } 10 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Repositories/DefaultMoviesQueriesRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DefaultMoviesQueriesRepository { 4 | 5 | private var moviesQueriesPersistentStorage: MoviesQueriesStorage 6 | 7 | init(moviesQueriesPersistentStorage: MoviesQueriesStorage) { 8 | self.moviesQueriesPersistentStorage = moviesQueriesPersistentStorage 9 | } 10 | } 11 | 12 | extension DefaultMoviesQueriesRepository: MoviesQueriesRepository { 13 | 14 | func fetchRecentsQueries( 15 | maxCount: Int, 16 | completion: @escaping (Result<[MovieQuery], Error>) -> Void 17 | ) { 18 | return moviesQueriesPersistentStorage.fetchRecentsQueries( 19 | maxCount: maxCount, 20 | completion: completion 21 | ) 22 | } 23 | 24 | func saveRecentQuery( 25 | query: MovieQuery, 26 | completion: @escaping (Result) -> Void 27 | ) { 28 | moviesQueriesPersistentStorage.saveRecentQuery( 29 | query: query, 30 | completion: completion 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Repositories/DefaultMoviesRepository.swift: -------------------------------------------------------------------------------- 1 | // **Note**: DTOs structs are mapped into Domains here, and Repository protocols does not contain DTOs 2 | 3 | import Foundation 4 | 5 | final class DefaultMoviesRepository { 6 | 7 | private let dataTransferService: DataTransferService 8 | private let cache: MoviesResponseStorage 9 | private let backgroundQueue: DataTransferDispatchQueue 10 | 11 | init( 12 | dataTransferService: DataTransferService, 13 | cache: MoviesResponseStorage, 14 | backgroundQueue: DataTransferDispatchQueue = DispatchQueue.global(qos: .userInitiated) 15 | ) { 16 | self.dataTransferService = dataTransferService 17 | self.cache = cache 18 | self.backgroundQueue = backgroundQueue 19 | } 20 | } 21 | 22 | extension DefaultMoviesRepository: MoviesRepository { 23 | 24 | func fetchMoviesList( 25 | query: MovieQuery, 26 | page: Int, 27 | cached: @escaping (MoviesPage) -> Void, 28 | completion: @escaping (Result) -> Void 29 | ) -> Cancellable? { 30 | 31 | let requestDTO = MoviesRequestDTO(query: query.query, page: page) 32 | let task = RepositoryTask() 33 | 34 | cache.getResponse(for: requestDTO) { [weak self, backgroundQueue] result in 35 | 36 | if case let .success(responseDTO?) = result { 37 | cached(responseDTO.toDomain()) 38 | } 39 | guard !task.isCancelled else { return } 40 | 41 | let endpoint = APIEndpoints.getMovies(with: requestDTO) 42 | task.networkTask = self?.dataTransferService.request( 43 | with: endpoint, 44 | on: backgroundQueue 45 | ) { result in 46 | switch result { 47 | case .success(let responseDTO): 48 | self?.cache.save(response: responseDTO, for: requestDTO) 49 | completion(.success(responseDTO.toDomain())) 50 | case .failure(let error): 51 | completion(.failure(error)) 52 | } 53 | } 54 | } 55 | return task 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Repositories/DefaultPosterImagesRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DefaultPosterImagesRepository { 4 | 5 | private let dataTransferService: DataTransferService 6 | private let backgroundQueue: DataTransferDispatchQueue 7 | 8 | init( 9 | dataTransferService: DataTransferService, 10 | backgroundQueue: DataTransferDispatchQueue = DispatchQueue.global(qos: .userInitiated) 11 | ) { 12 | self.dataTransferService = dataTransferService 13 | self.backgroundQueue = backgroundQueue 14 | } 15 | } 16 | 17 | extension DefaultPosterImagesRepository: PosterImagesRepository { 18 | 19 | func fetchImage( 20 | with imagePath: String, 21 | width: Int, 22 | completion: @escaping (Result) -> Void 23 | ) -> Cancellable? { 24 | 25 | let endpoint = APIEndpoints.getMoviePoster(path: imagePath, width: width) 26 | let task = RepositoryTask() 27 | task.networkTask = dataTransferService.request( 28 | with: endpoint, 29 | on: backgroundQueue 30 | ) { (result: Result) in 31 | 32 | let result = result.mapError { $0 as Error } 33 | completion(result) 34 | } 35 | return task 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ExampleMVVM/Data/Repositories/Utils/RepositoryTask.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class RepositoryTask: Cancellable { 4 | var networkTask: NetworkCancellable? 5 | var isCancelled: Bool = false 6 | 7 | func cancel() { 8 | networkTask?.cancel() 9 | isCancelled = true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Entities/Movie.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Movie: Equatable, Identifiable { 4 | typealias Identifier = String 5 | enum Genre { 6 | case adventure 7 | case scienceFiction 8 | } 9 | let id: Identifier 10 | let title: String? 11 | let genre: Genre? 12 | let posterPath: String? 13 | let overview: String? 14 | let releaseDate: Date? 15 | } 16 | 17 | struct MoviesPage: Equatable { 18 | let page: Int 19 | let totalPages: Int 20 | let movies: [Movie] 21 | } 22 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Entities/MovieQuery.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MovieQuery: Equatable { 4 | let query: String 5 | } 6 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Interfaces/Repositories/MoviesQueriesRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol MoviesQueriesRepository { 4 | func fetchRecentsQueries( 5 | maxCount: Int, 6 | completion: @escaping (Result<[MovieQuery], Error>) -> Void 7 | ) 8 | func saveRecentQuery( 9 | query: MovieQuery, 10 | completion: @escaping (Result) -> Void 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Interfaces/Repositories/MoviesRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol MoviesRepository { 4 | @discardableResult 5 | func fetchMoviesList( 6 | query: MovieQuery, 7 | page: Int, 8 | cached: @escaping (MoviesPage) -> Void, 9 | completion: @escaping (Result) -> Void 10 | ) -> Cancellable? 11 | } 12 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/Interfaces/Repositories/PosterImagesRepository.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol PosterImagesRepository { 4 | func fetchImage( 5 | with imagePath: String, 6 | width: Int, 7 | completion: @escaping (Result) -> Void 8 | ) -> Cancellable? 9 | } 10 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/UseCases/FetchRecentMovieQueriesUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // This is another option to create Use Case using more generic way 4 | final class FetchRecentMovieQueriesUseCase: UseCase { 5 | 6 | struct RequestValue { 7 | let maxCount: Int 8 | } 9 | typealias ResultValue = (Result<[MovieQuery], Error>) 10 | 11 | private let requestValue: RequestValue 12 | private let completion: (ResultValue) -> Void 13 | private let moviesQueriesRepository: MoviesQueriesRepository 14 | 15 | init( 16 | requestValue: RequestValue, 17 | completion: @escaping (ResultValue) -> Void, 18 | moviesQueriesRepository: MoviesQueriesRepository 19 | ) { 20 | 21 | self.requestValue = requestValue 22 | self.completion = completion 23 | self.moviesQueriesRepository = moviesQueriesRepository 24 | } 25 | 26 | func start() -> Cancellable? { 27 | 28 | moviesQueriesRepository.fetchRecentsQueries( 29 | maxCount: requestValue.maxCount, 30 | completion: completion 31 | ) 32 | return nil 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/UseCases/Protocol/UseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol UseCase { 4 | @discardableResult 5 | func start() -> Cancellable? 6 | } 7 | -------------------------------------------------------------------------------- /ExampleMVVM/Domain/UseCases/SearchMoviesUseCase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol SearchMoviesUseCase { 4 | func execute( 5 | requestValue: SearchMoviesUseCaseRequestValue, 6 | cached: @escaping (MoviesPage) -> Void, 7 | completion: @escaping (Result) -> Void 8 | ) -> Cancellable? 9 | } 10 | 11 | final class DefaultSearchMoviesUseCase: SearchMoviesUseCase { 12 | 13 | private let moviesRepository: MoviesRepository 14 | private let moviesQueriesRepository: MoviesQueriesRepository 15 | 16 | init( 17 | moviesRepository: MoviesRepository, 18 | moviesQueriesRepository: MoviesQueriesRepository 19 | ) { 20 | 21 | self.moviesRepository = moviesRepository 22 | self.moviesQueriesRepository = moviesQueriesRepository 23 | } 24 | 25 | func execute( 26 | requestValue: SearchMoviesUseCaseRequestValue, 27 | cached: @escaping (MoviesPage) -> Void, 28 | completion: @escaping (Result) -> Void 29 | ) -> Cancellable? { 30 | 31 | return moviesRepository.fetchMoviesList( 32 | query: requestValue.query, 33 | page: requestValue.page, 34 | cached: cached, 35 | completion: { result in 36 | 37 | if case .success = result { 38 | self.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in } 39 | } 40 | 41 | completion(result) 42 | }) 43 | } 44 | } 45 | 46 | struct SearchMoviesUseCaseRequestValue { 47 | let query: MovieQuery 48 | let page: Int 49 | } 50 | -------------------------------------------------------------------------------- /ExampleMVVM/Infrastructure/Network/DataTransferService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum DataTransferError: Error { 4 | case noResponse 5 | case parsing(Error) 6 | case networkFailure(NetworkError) 7 | case resolvedNetworkFailure(Error) 8 | } 9 | 10 | protocol DataTransferDispatchQueue { 11 | func asyncExecute(work: @escaping () -> Void) 12 | } 13 | 14 | extension DispatchQueue: DataTransferDispatchQueue { 15 | func asyncExecute(work: @escaping () -> Void) { 16 | async(group: nil, execute: work) 17 | } 18 | } 19 | 20 | protocol DataTransferService { 21 | typealias CompletionHandler = (Result) -> Void 22 | 23 | @discardableResult 24 | func request( 25 | with endpoint: E, 26 | on queue: DataTransferDispatchQueue, 27 | completion: @escaping CompletionHandler 28 | ) -> NetworkCancellable? where E.Response == T 29 | 30 | @discardableResult 31 | func request( 32 | with endpoint: E, 33 | completion: @escaping CompletionHandler 34 | ) -> NetworkCancellable? where E.Response == T 35 | 36 | @discardableResult 37 | func request( 38 | with endpoint: E, 39 | on queue: DataTransferDispatchQueue, 40 | completion: @escaping CompletionHandler 41 | ) -> NetworkCancellable? where E.Response == Void 42 | 43 | @discardableResult 44 | func request( 45 | with endpoint: E, 46 | completion: @escaping CompletionHandler 47 | ) -> NetworkCancellable? where E.Response == Void 48 | } 49 | 50 | protocol DataTransferErrorResolver { 51 | func resolve(error: NetworkError) -> Error 52 | } 53 | 54 | protocol ResponseDecoder { 55 | func decode(_ data: Data) throws -> T 56 | } 57 | 58 | protocol DataTransferErrorLogger { 59 | func log(error: Error) 60 | } 61 | 62 | final class DefaultDataTransferService { 63 | 64 | private let networkService: NetworkService 65 | private let errorResolver: DataTransferErrorResolver 66 | private let errorLogger: DataTransferErrorLogger 67 | 68 | init( 69 | with networkService: NetworkService, 70 | errorResolver: DataTransferErrorResolver = DefaultDataTransferErrorResolver(), 71 | errorLogger: DataTransferErrorLogger = DefaultDataTransferErrorLogger() 72 | ) { 73 | self.networkService = networkService 74 | self.errorResolver = errorResolver 75 | self.errorLogger = errorLogger 76 | } 77 | } 78 | 79 | extension DefaultDataTransferService: DataTransferService { 80 | 81 | func request( 82 | with endpoint: E, 83 | on queue: DataTransferDispatchQueue, 84 | completion: @escaping CompletionHandler 85 | ) -> NetworkCancellable? where E.Response == T { 86 | 87 | networkService.request(endpoint: endpoint) { result in 88 | switch result { 89 | case .success(let data): 90 | let result: Result = self.decode( 91 | data: data, 92 | decoder: endpoint.responseDecoder 93 | ) 94 | queue.asyncExecute { completion(result) } 95 | case .failure(let error): 96 | self.errorLogger.log(error: error) 97 | let error = self.resolve(networkError: error) 98 | queue.asyncExecute { completion(.failure(error)) } 99 | } 100 | } 101 | } 102 | 103 | func request( 104 | with endpoint: E, 105 | completion: @escaping CompletionHandler 106 | ) -> NetworkCancellable? where E.Response == T { 107 | request(with: endpoint, on: DispatchQueue.main, completion: completion) 108 | } 109 | 110 | func request( 111 | with endpoint: E, 112 | on queue: DataTransferDispatchQueue, 113 | completion: @escaping CompletionHandler 114 | ) -> NetworkCancellable? where E : ResponseRequestable, E.Response == Void { 115 | networkService.request(endpoint: endpoint) { result in 116 | switch result { 117 | case .success: 118 | queue.asyncExecute { completion(.success(())) } 119 | case .failure(let error): 120 | self.errorLogger.log(error: error) 121 | let error = self.resolve(networkError: error) 122 | queue.asyncExecute { completion(.failure(error)) } 123 | } 124 | } 125 | } 126 | 127 | func request( 128 | with endpoint: E, 129 | completion: @escaping CompletionHandler 130 | ) -> NetworkCancellable? where E : ResponseRequestable, E.Response == Void { 131 | request(with: endpoint, on: DispatchQueue.main, completion: completion) 132 | } 133 | 134 | // MARK: - Private 135 | private func decode( 136 | data: Data?, 137 | decoder: ResponseDecoder 138 | ) -> Result { 139 | do { 140 | guard let data = data else { return .failure(.noResponse) } 141 | let result: T = try decoder.decode(data) 142 | return .success(result) 143 | } catch { 144 | self.errorLogger.log(error: error) 145 | return .failure(.parsing(error)) 146 | } 147 | } 148 | 149 | private func resolve(networkError error: NetworkError) -> DataTransferError { 150 | let resolvedError = self.errorResolver.resolve(error: error) 151 | return resolvedError is NetworkError 152 | ? .networkFailure(error) 153 | : .resolvedNetworkFailure(resolvedError) 154 | } 155 | } 156 | 157 | // MARK: - Logger 158 | final class DefaultDataTransferErrorLogger: DataTransferErrorLogger { 159 | init() { } 160 | 161 | func log(error: Error) { 162 | printIfDebug("-------------") 163 | printIfDebug("\(error)") 164 | } 165 | } 166 | 167 | // MARK: - Error Resolver 168 | class DefaultDataTransferErrorResolver: DataTransferErrorResolver { 169 | init() { } 170 | func resolve(error: NetworkError) -> Error { 171 | return error 172 | } 173 | } 174 | 175 | // MARK: - Response Decoders 176 | class JSONResponseDecoder: ResponseDecoder { 177 | private let jsonDecoder = JSONDecoder() 178 | init() { } 179 | func decode(_ data: Data) throws -> T { 180 | return try jsonDecoder.decode(T.self, from: data) 181 | } 182 | } 183 | 184 | class RawDataResponseDecoder: ResponseDecoder { 185 | init() { } 186 | 187 | enum CodingKeys: String, CodingKey { 188 | case `default` = "" 189 | } 190 | func decode(_ data: Data) throws -> T { 191 | if T.self is Data.Type, let data = data as? T { 192 | return data 193 | } else { 194 | let context = DecodingError.Context( 195 | codingPath: [CodingKeys.default], 196 | debugDescription: "Expected Data type" 197 | ) 198 | throw Swift.DecodingError.typeMismatch(T.self, context) 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /ExampleMVVM/Infrastructure/Network/Endpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HTTPMethodType: String { 4 | case get = "GET" 5 | case head = "HEAD" 6 | case post = "POST" 7 | case put = "PUT" 8 | case patch = "PATCH" 9 | case delete = "DELETE" 10 | } 11 | 12 | class Endpoint: ResponseRequestable { 13 | 14 | typealias Response = R 15 | 16 | let path: String 17 | let isFullPath: Bool 18 | let method: HTTPMethodType 19 | let headerParameters: [String: String] 20 | let queryParametersEncodable: Encodable? 21 | let queryParameters: [String: Any] 22 | let bodyParametersEncodable: Encodable? 23 | let bodyParameters: [String: Any] 24 | let bodyEncoder: BodyEncoder 25 | let responseDecoder: ResponseDecoder 26 | 27 | init(path: String, 28 | isFullPath: Bool = false, 29 | method: HTTPMethodType, 30 | headerParameters: [String: String] = [:], 31 | queryParametersEncodable: Encodable? = nil, 32 | queryParameters: [String: Any] = [:], 33 | bodyParametersEncodable: Encodable? = nil, 34 | bodyParameters: [String: Any] = [:], 35 | bodyEncoder: BodyEncoder = JSONBodyEncoder(), 36 | responseDecoder: ResponseDecoder = JSONResponseDecoder()) { 37 | self.path = path 38 | self.isFullPath = isFullPath 39 | self.method = method 40 | self.headerParameters = headerParameters 41 | self.queryParametersEncodable = queryParametersEncodable 42 | self.queryParameters = queryParameters 43 | self.bodyParametersEncodable = bodyParametersEncodable 44 | self.bodyParameters = bodyParameters 45 | self.bodyEncoder = bodyEncoder 46 | self.responseDecoder = responseDecoder 47 | } 48 | } 49 | 50 | protocol BodyEncoder { 51 | func encode(_ parameters: [String: Any]) -> Data? 52 | } 53 | 54 | struct JSONBodyEncoder: BodyEncoder { 55 | func encode(_ parameters: [String: Any]) -> Data? { 56 | return try? JSONSerialization.data(withJSONObject: parameters) 57 | } 58 | } 59 | 60 | struct AsciiBodyEncoder: BodyEncoder { 61 | func encode(_ parameters: [String: Any]) -> Data? { 62 | return parameters.queryString.data(using: String.Encoding.ascii, allowLossyConversion: true) 63 | } 64 | } 65 | 66 | protocol Requestable { 67 | var path: String { get } 68 | var isFullPath: Bool { get } 69 | var method: HTTPMethodType { get } 70 | var headerParameters: [String: String] { get } 71 | var queryParametersEncodable: Encodable? { get } 72 | var queryParameters: [String: Any] { get } 73 | var bodyParametersEncodable: Encodable? { get } 74 | var bodyParameters: [String: Any] { get } 75 | var bodyEncoder: BodyEncoder { get } 76 | 77 | func urlRequest(with networkConfig: NetworkConfigurable) throws -> URLRequest 78 | } 79 | 80 | protocol ResponseRequestable: Requestable { 81 | associatedtype Response 82 | 83 | var responseDecoder: ResponseDecoder { get } 84 | } 85 | 86 | enum RequestGenerationError: Error { 87 | case components 88 | } 89 | 90 | extension Requestable { 91 | 92 | func url(with config: NetworkConfigurable) throws -> URL { 93 | 94 | let baseURL = config.baseURL.absoluteString.last != "/" 95 | ? config.baseURL.absoluteString + "/" 96 | : config.baseURL.absoluteString 97 | let endpoint = isFullPath ? path : baseURL.appending(path) 98 | 99 | guard var urlComponents = URLComponents( 100 | string: endpoint 101 | ) else { throw RequestGenerationError.components } 102 | var urlQueryItems = [URLQueryItem]() 103 | 104 | let queryParameters = try queryParametersEncodable?.toDictionary() ?? self.queryParameters 105 | queryParameters.forEach { 106 | urlQueryItems.append(URLQueryItem(name: $0.key, value: "\($0.value)")) 107 | } 108 | config.queryParameters.forEach { 109 | urlQueryItems.append(URLQueryItem(name: $0.key, value: $0.value)) 110 | } 111 | urlComponents.queryItems = !urlQueryItems.isEmpty ? urlQueryItems : nil 112 | guard let url = urlComponents.url else { throw RequestGenerationError.components } 113 | return url 114 | } 115 | 116 | func urlRequest(with config: NetworkConfigurable) throws -> URLRequest { 117 | 118 | let url = try self.url(with: config) 119 | var urlRequest = URLRequest(url: url) 120 | var allHeaders: [String: String] = config.headers 121 | headerParameters.forEach { allHeaders.updateValue($1, forKey: $0) } 122 | 123 | let bodyParameters = try bodyParametersEncodable?.toDictionary() ?? self.bodyParameters 124 | if !bodyParameters.isEmpty { 125 | urlRequest.httpBody = bodyEncoder.encode(bodyParameters) 126 | } 127 | urlRequest.httpMethod = method.rawValue 128 | urlRequest.allHTTPHeaderFields = allHeaders 129 | return urlRequest 130 | } 131 | } 132 | 133 | private extension Dictionary { 134 | var queryString: String { 135 | return self.map { "\($0.key)=\($0.value)" } 136 | .joined(separator: "&") 137 | .addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed) ?? "" 138 | } 139 | } 140 | 141 | private extension Encodable { 142 | func toDictionary() throws -> [String: Any]? { 143 | let data = try JSONEncoder().encode(self) 144 | let jsonData = try JSONSerialization.jsonObject(with: data) 145 | return jsonData as? [String : Any] 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /ExampleMVVM/Infrastructure/Network/NetworkConfig.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol NetworkConfigurable { 4 | var baseURL: URL { get } 5 | var headers: [String: String] { get } 6 | var queryParameters: [String: String] { get } 7 | } 8 | 9 | struct ApiDataNetworkConfig: NetworkConfigurable { 10 | let baseURL: URL 11 | let headers: [String: String] 12 | let queryParameters: [String: String] 13 | 14 | init( 15 | baseURL: URL, 16 | headers: [String: String] = [:], 17 | queryParameters: [String: String] = [:] 18 | ) { 19 | self.baseURL = baseURL 20 | self.headers = headers 21 | self.queryParameters = queryParameters 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ExampleMVVM/Infrastructure/Network/NetworkService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum NetworkError: Error { 4 | case error(statusCode: Int, data: Data?) 5 | case notConnected 6 | case cancelled 7 | case generic(Error) 8 | case urlGeneration 9 | } 10 | 11 | protocol NetworkCancellable { 12 | func cancel() 13 | } 14 | 15 | extension URLSessionTask: NetworkCancellable { } 16 | 17 | protocol NetworkService { 18 | typealias CompletionHandler = (Result) -> Void 19 | 20 | func request(endpoint: Requestable, completion: @escaping CompletionHandler) -> NetworkCancellable? 21 | } 22 | 23 | protocol NetworkSessionManager { 24 | typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void 25 | 26 | func request(_ request: URLRequest, 27 | completion: @escaping CompletionHandler) -> NetworkCancellable 28 | } 29 | 30 | protocol NetworkErrorLogger { 31 | func log(request: URLRequest) 32 | func log(responseData data: Data?, response: URLResponse?) 33 | func log(error: Error) 34 | } 35 | 36 | // MARK: - Implementation 37 | 38 | final class DefaultNetworkService { 39 | 40 | private let config: NetworkConfigurable 41 | private let sessionManager: NetworkSessionManager 42 | private let logger: NetworkErrorLogger 43 | 44 | init( 45 | config: NetworkConfigurable, 46 | sessionManager: NetworkSessionManager = DefaultNetworkSessionManager(), 47 | logger: NetworkErrorLogger = DefaultNetworkErrorLogger() 48 | ) { 49 | self.sessionManager = sessionManager 50 | self.config = config 51 | self.logger = logger 52 | } 53 | 54 | private func request( 55 | request: URLRequest, 56 | completion: @escaping CompletionHandler 57 | ) -> NetworkCancellable { 58 | 59 | let sessionDataTask = sessionManager.request(request) { data, response, requestError in 60 | 61 | if let requestError = requestError { 62 | var error: NetworkError 63 | if let response = response as? HTTPURLResponse { 64 | error = .error(statusCode: response.statusCode, data: data) 65 | } else { 66 | error = self.resolve(error: requestError) 67 | } 68 | 69 | self.logger.log(error: error) 70 | completion(.failure(error)) 71 | } else { 72 | self.logger.log(responseData: data, response: response) 73 | completion(.success(data)) 74 | } 75 | } 76 | 77 | logger.log(request: request) 78 | 79 | return sessionDataTask 80 | } 81 | 82 | private func resolve(error: Error) -> NetworkError { 83 | let code = URLError.Code(rawValue: (error as NSError).code) 84 | switch code { 85 | case .notConnectedToInternet: return .notConnected 86 | case .cancelled: return .cancelled 87 | default: return .generic(error) 88 | } 89 | } 90 | } 91 | 92 | extension DefaultNetworkService: NetworkService { 93 | 94 | func request( 95 | endpoint: Requestable, 96 | completion: @escaping CompletionHandler 97 | ) -> NetworkCancellable? { 98 | do { 99 | let urlRequest = try endpoint.urlRequest(with: config) 100 | return request(request: urlRequest, completion: completion) 101 | } catch { 102 | completion(.failure(.urlGeneration)) 103 | return nil 104 | } 105 | } 106 | } 107 | 108 | // MARK: - Default Network Session Manager 109 | // Note: If authorization is needed NetworkSessionManager can be implemented by using, 110 | // for example, Alamofire SessionManager with its RequestAdapter and RequestRetrier. 111 | // And it can be injected into NetworkService instead of default one. 112 | 113 | final class DefaultNetworkSessionManager: NetworkSessionManager { 114 | func request( 115 | _ request: URLRequest, 116 | completion: @escaping CompletionHandler 117 | ) -> NetworkCancellable { 118 | let task = URLSession.shared.dataTask(with: request, completionHandler: completion) 119 | task.resume() 120 | return task 121 | } 122 | } 123 | 124 | // MARK: - Logger 125 | 126 | final class DefaultNetworkErrorLogger: NetworkErrorLogger { 127 | init() { } 128 | 129 | func log(request: URLRequest) { 130 | print("-------------") 131 | print("request: \(request.url!)") 132 | print("headers: \(request.allHTTPHeaderFields!)") 133 | print("method: \(request.httpMethod!)") 134 | if let httpBody = request.httpBody, let result = ((try? JSONSerialization.jsonObject(with: httpBody, options: []) as? [String: AnyObject]) as [String: AnyObject]??) { 135 | printIfDebug("body: \(String(describing: result))") 136 | } else if let httpBody = request.httpBody, let resultString = String(data: httpBody, encoding: .utf8) { 137 | printIfDebug("body: \(String(describing: resultString))") 138 | } 139 | } 140 | 141 | func log(responseData data: Data?, response: URLResponse?) { 142 | guard let data = data else { return } 143 | if let dataDict = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { 144 | printIfDebug("responseData: \(String(describing: dataDict))") 145 | } 146 | } 147 | 148 | func log(error: Error) { 149 | printIfDebug("\(error)") 150 | } 151 | } 152 | 153 | // MARK: - NetworkError extension 154 | 155 | extension NetworkError { 156 | var isNotFoundError: Bool { return hasStatusCode(404) } 157 | 158 | func hasStatusCode(_ codeError: Int) -> Bool { 159 | switch self { 160 | case let .error(code, _): 161 | return code == codeError 162 | default: return false 163 | } 164 | } 165 | } 166 | 167 | extension Dictionary where Key == String { 168 | func prettyPrint() -> String { 169 | var string: String = "" 170 | if let data = try? JSONSerialization.data(withJSONObject: self, options: .prettyPrinted) { 171 | if let nstr = NSString(data: data, encoding: String.Encoding.utf8.rawValue) { 172 | string = nstr as String 173 | } 174 | } 175 | return string 176 | } 177 | } 178 | 179 | func printIfDebug(_ string: String) { 180 | #if DEBUG 181 | print(string) 182 | #endif 183 | } 184 | -------------------------------------------------------------------------------- /ExampleMVVM/Mocks/DispatchQueueTypeMock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DispatchQueueTypeMock: DispatchQueueType { 4 | func async(execute work: @escaping () -> Void) { 5 | work() 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/Behaviors/BackButtonEmptyTitleNavigationBarBehavior.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct BackButtonEmptyTitleNavigationBarBehavior: ViewControllerLifecycleBehavior { 4 | 5 | func viewDidLoad(viewController: UIViewController) { 6 | 7 | viewController.navigationItem.backBarButtonItem = UIBarButtonItem( 8 | title: "", 9 | style: .plain, 10 | target: nil, 11 | action: nil 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/Behaviors/BlackStyleNavigationBarBehavior.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | struct BlackStyleNavigationBarBehavior: ViewControllerLifecycleBehavior { 4 | 5 | func viewDidLoad(viewController: UIViewController) { 6 | 7 | viewController.navigationController?.navigationBar.barStyle = .black 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/Flows/MoviesSearchFlowCoordinator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol MoviesSearchFlowCoordinatorDependencies { 4 | func makeMoviesListViewController( 5 | actions: MoviesListViewModelActions 6 | ) -> MoviesListViewController 7 | func makeMoviesDetailsViewController(movie: Movie) -> UIViewController 8 | func makeMoviesQueriesSuggestionsListViewController( 9 | didSelect: @escaping MoviesQueryListViewModelDidSelectAction 10 | ) -> UIViewController 11 | } 12 | 13 | final class MoviesSearchFlowCoordinator { 14 | 15 | private weak var navigationController: UINavigationController? 16 | private let dependencies: MoviesSearchFlowCoordinatorDependencies 17 | 18 | private weak var moviesListVC: MoviesListViewController? 19 | private weak var moviesQueriesSuggestionsVC: UIViewController? 20 | 21 | init(navigationController: UINavigationController, 22 | dependencies: MoviesSearchFlowCoordinatorDependencies) { 23 | self.navigationController = navigationController 24 | self.dependencies = dependencies 25 | } 26 | 27 | func start() { 28 | // Note: here we keep strong reference with actions, this way this flow do not need to be strong referenced 29 | let actions = MoviesListViewModelActions(showMovieDetails: showMovieDetails, 30 | showMovieQueriesSuggestions: showMovieQueriesSuggestions, 31 | closeMovieQueriesSuggestions: closeMovieQueriesSuggestions) 32 | let vc = dependencies.makeMoviesListViewController(actions: actions) 33 | 34 | navigationController?.pushViewController(vc, animated: false) 35 | moviesListVC = vc 36 | } 37 | 38 | private func showMovieDetails(movie: Movie) { 39 | let vc = dependencies.makeMoviesDetailsViewController(movie: movie) 40 | navigationController?.pushViewController(vc, animated: true) 41 | } 42 | 43 | private func showMovieQueriesSuggestions(didSelect: @escaping (MovieQuery) -> Void) { 44 | guard let moviesListViewController = moviesListVC, moviesQueriesSuggestionsVC == nil, 45 | let container = moviesListViewController.suggestionsListContainer else { return } 46 | 47 | let vc = dependencies.makeMoviesQueriesSuggestionsListViewController(didSelect: didSelect) 48 | 49 | moviesListViewController.add(child: vc, container: container) 50 | moviesQueriesSuggestionsVC = vc 51 | container.isHidden = false 52 | } 53 | 54 | private func closeMovieQueriesSuggestions() { 55 | moviesQueriesSuggestionsVC?.remove() 56 | moviesQueriesSuggestionsVC = nil 57 | moviesListVC?.suggestionsListContainer.isHidden = true 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MovieDetails/View/MovieDetailsViewController.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 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. 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 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MovieDetails/View/MovieDetailsViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class MovieDetailsViewController: UIViewController, StoryboardInstantiable { 4 | 5 | @IBOutlet private var posterImageView: UIImageView! 6 | @IBOutlet private var overviewTextView: UITextView! 7 | 8 | // MARK: - Lifecycle 9 | 10 | private var viewModel: MovieDetailsViewModel! 11 | 12 | static func create(with viewModel: MovieDetailsViewModel) -> MovieDetailsViewController { 13 | let view = MovieDetailsViewController.instantiateViewController() 14 | view.viewModel = viewModel 15 | return view 16 | } 17 | 18 | override func viewDidLoad() { 19 | super.viewDidLoad() 20 | setupViews() 21 | bind(to: viewModel) 22 | } 23 | 24 | private func bind(to viewModel: MovieDetailsViewModel) { 25 | viewModel.posterImage.observe(on: self) { [weak self] in self?.posterImageView.image = $0.flatMap(UIImage.init) } 26 | } 27 | 28 | override func viewDidLayoutSubviews() { 29 | super.viewDidLayoutSubviews() 30 | viewModel.updatePosterImage(width: Int(posterImageView.imageSizeAfterAspectFit.scaledSize.width)) 31 | } 32 | 33 | // MARK: - Private 34 | 35 | private func setupViews() { 36 | title = viewModel.title 37 | overviewTextView.text = viewModel.overview 38 | posterImageView.isHidden = viewModel.isPosterImageHidden 39 | view.accessibilityIdentifier = AccessibilityIdentifier.movieDetailsView 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MovieDetails/ViewModel/MovieDetailsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol MovieDetailsViewModelInput { 4 | func updatePosterImage(width: Int) 5 | } 6 | 7 | protocol MovieDetailsViewModelOutput { 8 | var title: String { get } 9 | var posterImage: Observable { get } 10 | var isPosterImageHidden: Bool { get } 11 | var overview: String { get } 12 | } 13 | 14 | protocol MovieDetailsViewModel: MovieDetailsViewModelInput, MovieDetailsViewModelOutput { } 15 | 16 | final class DefaultMovieDetailsViewModel: MovieDetailsViewModel { 17 | 18 | private let posterImagePath: String? 19 | private let posterImagesRepository: PosterImagesRepository 20 | private var imageLoadTask: Cancellable? { willSet { imageLoadTask?.cancel() } } 21 | private let mainQueue: DispatchQueueType 22 | 23 | // MARK: - OUTPUT 24 | let title: String 25 | let posterImage: Observable = Observable(nil) 26 | let isPosterImageHidden: Bool 27 | let overview: String 28 | 29 | init( 30 | movie: Movie, 31 | posterImagesRepository: PosterImagesRepository, 32 | mainQueue: DispatchQueueType = DispatchQueue.main 33 | ) { 34 | self.title = movie.title ?? "" 35 | self.overview = movie.overview ?? "" 36 | self.posterImagePath = movie.posterPath 37 | self.isPosterImageHidden = movie.posterPath == nil 38 | self.posterImagesRepository = posterImagesRepository 39 | self.mainQueue = mainQueue 40 | } 41 | } 42 | 43 | // MARK: - INPUT. View event methods 44 | extension DefaultMovieDetailsViewModel { 45 | 46 | func updatePosterImage(width: Int) { 47 | guard let posterImagePath = posterImagePath else { return } 48 | 49 | imageLoadTask = posterImagesRepository.fetchImage( 50 | with: posterImagePath, 51 | width: width 52 | ) { [weak self] result in 53 | self?.mainQueue.async { 54 | guard self?.posterImagePath == posterImagePath else { return } 55 | switch result { 56 | case .success(let data): 57 | self?.posterImage.value = data 58 | case .failure: break 59 | } 60 | self?.imageLoadTask = nil 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/View/MoviesListTableView/Cells/MoviesListItemCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class MoviesListItemCell: UITableViewCell { 4 | 5 | static let reuseIdentifier = String(describing: MoviesListItemCell.self) 6 | static let height = CGFloat(130) 7 | 8 | @IBOutlet private var titleLabel: UILabel! 9 | @IBOutlet private var dateLabel: UILabel! 10 | @IBOutlet private var overviewLabel: UILabel! 11 | @IBOutlet private var posterImageView: UIImageView! 12 | 13 | private var viewModel: MoviesListItemViewModel! 14 | private var posterImagesRepository: PosterImagesRepository? 15 | private var imageLoadTask: Cancellable? { willSet { imageLoadTask?.cancel() } } 16 | private let mainQueue: DispatchQueueType = DispatchQueue.main 17 | 18 | func fill( 19 | with viewModel: MoviesListItemViewModel, 20 | posterImagesRepository: PosterImagesRepository? 21 | ) { 22 | self.viewModel = viewModel 23 | self.posterImagesRepository = posterImagesRepository 24 | 25 | titleLabel.text = viewModel.title 26 | dateLabel.text = viewModel.releaseDate 27 | overviewLabel.text = viewModel.overview 28 | updatePosterImage(width: Int(posterImageView.imageSizeAfterAspectFit.scaledSize.width)) 29 | } 30 | 31 | private func updatePosterImage(width: Int) { 32 | posterImageView.image = nil 33 | guard let posterImagePath = viewModel.posterImagePath else { return } 34 | 35 | imageLoadTask = posterImagesRepository?.fetchImage( 36 | with: posterImagePath, 37 | width: width 38 | ) { [weak self] result in 39 | self?.mainQueue.async { 40 | guard self?.viewModel.posterImagePath == posterImagePath else { return } 41 | if case let .success(data) = result { 42 | self?.posterImageView.image = UIImage(data: data) 43 | } 44 | self?.imageLoadTask = nil 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/View/MoviesListTableView/MoviesListTableViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class MoviesListTableViewController: UITableViewController { 4 | 5 | var viewModel: MoviesListViewModel! 6 | 7 | var posterImagesRepository: PosterImagesRepository? 8 | var nextPageLoadingSpinner: UIActivityIndicatorView? 9 | 10 | // MARK: - Lifecycle 11 | 12 | override func viewDidLoad() { 13 | super.viewDidLoad() 14 | setupViews() 15 | } 16 | 17 | func reload() { 18 | tableView.reloadData() 19 | } 20 | 21 | func updateLoading(_ loading: MoviesListViewModelLoading?) { 22 | switch loading { 23 | case .nextPage: 24 | nextPageLoadingSpinner?.removeFromSuperview() 25 | nextPageLoadingSpinner = makeActivityIndicator(size: .init(width: tableView.frame.width, height: 44)) 26 | tableView.tableFooterView = nextPageLoadingSpinner 27 | case .fullScreen, .none: 28 | tableView.tableFooterView = nil 29 | } 30 | } 31 | 32 | // MARK: - Private 33 | 34 | private func setupViews() { 35 | tableView.estimatedRowHeight = MoviesListItemCell.height 36 | tableView.rowHeight = UITableView.automaticDimension 37 | } 38 | } 39 | 40 | // MARK: - UITableViewDataSource, UITableViewDelegate 41 | 42 | extension MoviesListTableViewController { 43 | 44 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 45 | return viewModel.items.value.count 46 | } 47 | 48 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 49 | guard let cell = tableView.dequeueReusableCell( 50 | withIdentifier: MoviesListItemCell.reuseIdentifier, 51 | for: indexPath 52 | ) as? MoviesListItemCell else { 53 | assertionFailure("Cannot dequeue reusable cell \(MoviesListItemCell.self) with reuseIdentifier: \(MoviesListItemCell.reuseIdentifier)") 54 | return UITableViewCell() 55 | } 56 | 57 | cell.fill(with: viewModel.items.value[indexPath.row], 58 | posterImagesRepository: posterImagesRepository) 59 | 60 | if indexPath.row == viewModel.items.value.count - 1 { 61 | viewModel.didLoadNextPage() 62 | } 63 | 64 | return cell 65 | } 66 | 67 | override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 68 | return viewModel.isEmpty ? tableView.frame.height : super.tableView(tableView, heightForRowAt: indexPath) 69 | } 70 | 71 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 72 | viewModel.didSelectItem(at: indexPath.row) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/View/MoviesListViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class MoviesListViewController: UIViewController, StoryboardInstantiable, Alertable { 4 | 5 | @IBOutlet private var contentView: UIView! 6 | @IBOutlet private var moviesListContainer: UIView! 7 | @IBOutlet private(set) var suggestionsListContainer: UIView! 8 | @IBOutlet private var searchBarContainer: UIView! 9 | @IBOutlet private var emptyDataLabel: UILabel! 10 | 11 | private var viewModel: MoviesListViewModel! 12 | private var posterImagesRepository: PosterImagesRepository? 13 | 14 | private var moviesTableViewController: MoviesListTableViewController? 15 | private var searchController = UISearchController(searchResultsController: nil) 16 | 17 | // MARK: - Lifecycle 18 | 19 | static func create( 20 | with viewModel: MoviesListViewModel, 21 | posterImagesRepository: PosterImagesRepository? 22 | ) -> MoviesListViewController { 23 | let view = MoviesListViewController.instantiateViewController() 24 | view.viewModel = viewModel 25 | view.posterImagesRepository = posterImagesRepository 26 | return view 27 | } 28 | 29 | override func viewDidLoad() { 30 | super.viewDidLoad() 31 | setupViews() 32 | setupBehaviours() 33 | bind(to: viewModel) 34 | viewModel.viewDidLoad() 35 | } 36 | 37 | private func bind(to viewModel: MoviesListViewModel) { 38 | viewModel.items.observe(on: self) { [weak self] _ in self?.updateItems() } 39 | viewModel.loading.observe(on: self) { [weak self] in self?.updateLoading($0) } 40 | viewModel.query.observe(on: self) { [weak self] in self?.updateSearchQuery($0) } 41 | viewModel.error.observe(on: self) { [weak self] in self?.showError($0) } 42 | } 43 | 44 | override func viewWillDisappear(_ animated: Bool) { 45 | super.viewWillDisappear(animated) 46 | searchController.isActive = false 47 | } 48 | 49 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 50 | if segue.identifier == String(describing: MoviesListTableViewController.self), 51 | let destinationVC = segue.destination as? MoviesListTableViewController { 52 | moviesTableViewController = destinationVC 53 | moviesTableViewController?.viewModel = viewModel 54 | moviesTableViewController?.posterImagesRepository = posterImagesRepository 55 | } 56 | } 57 | 58 | // MARK: - Private 59 | 60 | private func setupViews() { 61 | title = viewModel.screenTitle 62 | emptyDataLabel.text = viewModel.emptyDataTitle 63 | setupSearchController() 64 | } 65 | 66 | private func setupBehaviours() { 67 | addBehaviors([BackButtonEmptyTitleNavigationBarBehavior(), 68 | BlackStyleNavigationBarBehavior()]) 69 | } 70 | 71 | private func updateItems() { 72 | moviesTableViewController?.reload() 73 | } 74 | 75 | private func updateLoading(_ loading: MoviesListViewModelLoading?) { 76 | emptyDataLabel.isHidden = true 77 | moviesListContainer.isHidden = true 78 | suggestionsListContainer.isHidden = true 79 | LoadingView.hide() 80 | 81 | switch loading { 82 | case .fullScreen: LoadingView.show() 83 | case .nextPage: moviesListContainer.isHidden = false 84 | case .none: 85 | moviesListContainer.isHidden = viewModel.isEmpty 86 | emptyDataLabel.isHidden = !viewModel.isEmpty 87 | } 88 | 89 | moviesTableViewController?.updateLoading(loading) 90 | updateQueriesSuggestions() 91 | } 92 | 93 | private func updateQueriesSuggestions() { 94 | guard searchController.searchBar.isFirstResponder else { 95 | viewModel.closeQueriesSuggestions() 96 | return 97 | } 98 | viewModel.showQueriesSuggestions() 99 | } 100 | 101 | private func updateSearchQuery(_ query: String) { 102 | searchController.isActive = false 103 | searchController.searchBar.text = query 104 | } 105 | 106 | private func showError(_ error: String) { 107 | guard !error.isEmpty else { return } 108 | showAlert(title: viewModel.errorTitle, message: error) 109 | } 110 | } 111 | 112 | // MARK: - Search Controller 113 | 114 | extension MoviesListViewController { 115 | private func setupSearchController() { 116 | searchController.delegate = self 117 | searchController.searchBar.delegate = self 118 | searchController.searchBar.placeholder = viewModel.searchBarPlaceholder 119 | searchController.obscuresBackgroundDuringPresentation = false 120 | searchController.searchBar.translatesAutoresizingMaskIntoConstraints = true 121 | searchController.searchBar.barStyle = .black 122 | searchController.hidesNavigationBarDuringPresentation = false 123 | searchController.searchBar.frame = searchBarContainer.bounds 124 | searchController.searchBar.autoresizingMask = [.flexibleWidth] 125 | searchBarContainer.addSubview(searchController.searchBar) 126 | definesPresentationContext = true 127 | if #available(iOS 13.0, *) { 128 | searchController.searchBar.searchTextField.accessibilityIdentifier = AccessibilityIdentifier.searchField 129 | } 130 | } 131 | } 132 | 133 | extension MoviesListViewController: UISearchBarDelegate { 134 | func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { 135 | guard let searchText = searchBar.text, !searchText.isEmpty else { return } 136 | searchController.isActive = false 137 | viewModel.didSearch(query: searchText) 138 | } 139 | 140 | func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { 141 | viewModel.didCancelSearch() 142 | } 143 | } 144 | 145 | extension MoviesListViewController: UISearchControllerDelegate { 146 | func willPresentSearchController(_ searchController: UISearchController) { 147 | updateQueriesSuggestions() 148 | } 149 | 150 | func willDismissSearchController(_ searchController: UISearchController) { 151 | updateQueriesSuggestions() 152 | } 153 | 154 | func didDismissSearchController(_ searchController: UISearchController) { 155 | updateQueriesSuggestions() 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/ViewModel/MoviesListItemViewModel.swift: -------------------------------------------------------------------------------- 1 | // **Note**: This item view model is to display data and does not contain any domain model to prevent views accessing it 2 | 3 | import Foundation 4 | 5 | struct MoviesListItemViewModel: Equatable { 6 | let title: String 7 | let overview: String 8 | let releaseDate: String 9 | let posterImagePath: String? 10 | } 11 | 12 | extension MoviesListItemViewModel { 13 | 14 | init(movie: Movie) { 15 | self.title = movie.title ?? "" 16 | self.posterImagePath = movie.posterPath 17 | self.overview = movie.overview ?? "" 18 | if let releaseDate = movie.releaseDate { 19 | self.releaseDate = "\(NSLocalizedString("Release Date", comment: "")): \(dateFormatter.string(from: releaseDate))" 20 | } else { 21 | self.releaseDate = NSLocalizedString("To be announced", comment: "") 22 | } 23 | } 24 | } 25 | 26 | private let dateFormatter: DateFormatter = { 27 | let formatter = DateFormatter() 28 | formatter.dateStyle = .medium 29 | return formatter 30 | }() 31 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesList/ViewModel/MoviesListViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MoviesListViewModelActions { 4 | /// Note: if you would need to edit movie inside Details screen and update this Movies List screen with updated movie then you would need this closure: 5 | /// showMovieDetails: (Movie, @escaping (_ updated: Movie) -> Void) -> Void 6 | let showMovieDetails: (Movie) -> Void 7 | let showMovieQueriesSuggestions: (@escaping (_ didSelect: MovieQuery) -> Void) -> Void 8 | let closeMovieQueriesSuggestions: () -> Void 9 | } 10 | 11 | enum MoviesListViewModelLoading { 12 | case fullScreen 13 | case nextPage 14 | } 15 | 16 | protocol MoviesListViewModelInput { 17 | func viewDidLoad() 18 | func didLoadNextPage() 19 | func didSearch(query: String) 20 | func didCancelSearch() 21 | func showQueriesSuggestions() 22 | func closeQueriesSuggestions() 23 | func didSelectItem(at index: Int) 24 | } 25 | 26 | protocol MoviesListViewModelOutput { 27 | var items: Observable<[MoviesListItemViewModel]> { get } /// Also we can calculate view model items on demand: https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/pull/10/files 28 | var loading: Observable { get } 29 | var query: Observable { get } 30 | var error: Observable { get } 31 | var isEmpty: Bool { get } 32 | var screenTitle: String { get } 33 | var emptyDataTitle: String { get } 34 | var errorTitle: String { get } 35 | var searchBarPlaceholder: String { get } 36 | } 37 | 38 | typealias MoviesListViewModel = MoviesListViewModelInput & MoviesListViewModelOutput 39 | 40 | final class DefaultMoviesListViewModel: MoviesListViewModel { 41 | 42 | private let searchMoviesUseCase: SearchMoviesUseCase 43 | private let actions: MoviesListViewModelActions? 44 | 45 | var currentPage: Int = 0 46 | var totalPageCount: Int = 1 47 | var hasMorePages: Bool { currentPage < totalPageCount } 48 | var nextPage: Int { hasMorePages ? currentPage + 1 : currentPage } 49 | 50 | private var pages: [MoviesPage] = [] 51 | private var moviesLoadTask: Cancellable? { willSet { moviesLoadTask?.cancel() } } 52 | private let mainQueue: DispatchQueueType 53 | 54 | // MARK: - OUTPUT 55 | 56 | let items: Observable<[MoviesListItemViewModel]> = Observable([]) 57 | let loading: Observable = Observable(.none) 58 | let query: Observable = Observable("") 59 | let error: Observable = Observable("") 60 | var isEmpty: Bool { return items.value.isEmpty } 61 | let screenTitle = NSLocalizedString("Movies", comment: "") 62 | let emptyDataTitle = NSLocalizedString("Search results", comment: "") 63 | let errorTitle = NSLocalizedString("Error", comment: "") 64 | let searchBarPlaceholder = NSLocalizedString("Search Movies", comment: "") 65 | 66 | // MARK: - Init 67 | 68 | init( 69 | searchMoviesUseCase: SearchMoviesUseCase, 70 | actions: MoviesListViewModelActions? = nil, 71 | mainQueue: DispatchQueueType = DispatchQueue.main 72 | ) { 73 | self.searchMoviesUseCase = searchMoviesUseCase 74 | self.actions = actions 75 | self.mainQueue = mainQueue 76 | } 77 | 78 | // MARK: - Private 79 | 80 | private func appendPage(_ moviesPage: MoviesPage) { 81 | currentPage = moviesPage.page 82 | totalPageCount = moviesPage.totalPages 83 | 84 | pages = pages 85 | .filter { $0.page != moviesPage.page } 86 | + [moviesPage] 87 | 88 | items.value = pages.movies.map(MoviesListItemViewModel.init) 89 | } 90 | 91 | private func resetPages() { 92 | currentPage = 0 93 | totalPageCount = 1 94 | pages.removeAll() 95 | items.value.removeAll() 96 | } 97 | 98 | private func load(movieQuery: MovieQuery, loading: MoviesListViewModelLoading) { 99 | self.loading.value = loading 100 | query.value = movieQuery.query 101 | 102 | moviesLoadTask = searchMoviesUseCase.execute( 103 | requestValue: .init(query: movieQuery, page: nextPage), 104 | cached: { [weak self] page in 105 | self?.mainQueue.async { 106 | self?.appendPage(page) 107 | } 108 | }, 109 | completion: { [weak self] result in 110 | self?.mainQueue.async { 111 | switch result { 112 | case .success(let page): 113 | self?.appendPage(page) 114 | case .failure(let error): 115 | self?.handle(error: error) 116 | } 117 | self?.loading.value = .none 118 | } 119 | }) 120 | } 121 | 122 | private func handle(error: Error) { 123 | self.error.value = error.isInternetConnectionError ? 124 | NSLocalizedString("No internet connection", comment: "") : 125 | NSLocalizedString("Failed loading movies", comment: "") 126 | } 127 | 128 | private func update(movieQuery: MovieQuery) { 129 | resetPages() 130 | load(movieQuery: movieQuery, loading: .fullScreen) 131 | } 132 | } 133 | 134 | // MARK: - INPUT. View event methods 135 | 136 | extension DefaultMoviesListViewModel { 137 | 138 | func viewDidLoad() { } 139 | 140 | func didLoadNextPage() { 141 | guard hasMorePages, loading.value == .none else { return } 142 | load(movieQuery: .init(query: query.value), 143 | loading: .nextPage) 144 | } 145 | 146 | func didSearch(query: String) { 147 | guard !query.isEmpty else { return } 148 | update(movieQuery: MovieQuery(query: query)) 149 | } 150 | 151 | func didCancelSearch() { 152 | moviesLoadTask?.cancel() 153 | } 154 | 155 | func showQueriesSuggestions() { 156 | actions?.showMovieQueriesSuggestions(update(movieQuery:)) 157 | } 158 | 159 | func closeQueriesSuggestions() { 160 | actions?.closeMovieQueriesSuggestions() 161 | } 162 | 163 | func didSelectItem(at index: Int) { 164 | actions?.showMovieDetails(pages.movies[index]) 165 | } 166 | } 167 | 168 | // MARK: - Private 169 | 170 | private extension Array where Element == MoviesPage { 171 | var movies: [Movie] { flatMap { $0.movies } } 172 | } 173 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/View/SwiftUI/MoviesQueryListView.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | @available(iOS 13.0, *) 5 | extension MoviesQueryListItemViewModel: Identifiable { } 6 | 7 | @available(iOS 13.0, *) 8 | struct MoviesQueryListView: View { 9 | @ObservedObject var viewModelWrapper: MoviesQueryListViewModelWrapper 10 | 11 | var body: some View { 12 | List(viewModelWrapper.items) { item in 13 | Button(action: { 14 | self.viewModelWrapper.viewModel?.didSelect(item: item) 15 | }) { 16 | Text(item.query) 17 | } 18 | } 19 | .onAppear { 20 | self.viewModelWrapper.viewModel?.viewWillAppear() 21 | } 22 | } 23 | } 24 | 25 | @available(iOS 13.0, *) 26 | final class MoviesQueryListViewModelWrapper: ObservableObject { 27 | var viewModel: MoviesQueryListViewModel? 28 | @Published var items: [MoviesQueryListItemViewModel] = [] 29 | 30 | init(viewModel: MoviesQueryListViewModel?) { 31 | self.viewModel = viewModel 32 | viewModel?.items.observe(on: self) { [weak self] values in self?.items = values } 33 | } 34 | } 35 | 36 | #if DEBUG 37 | @available(iOS 13.0, *) 38 | struct MoviesQueryListView_Previews: PreviewProvider { 39 | static var previews: some View { 40 | MoviesQueryListView(viewModelWrapper: previewViewModelWrapper) 41 | } 42 | 43 | static var previewViewModelWrapper: MoviesQueryListViewModelWrapper = { 44 | var viewModel = MoviesQueryListViewModelWrapper(viewModel: nil) 45 | viewModel.items = [MoviesQueryListItemViewModel(query: "item 1"), 46 | MoviesQueryListItemViewModel(query: "item 2") 47 | ] 48 | return viewModel 49 | }() 50 | } 51 | #endif 52 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/View/UIKit/Cells/MoviesQueriesItemCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class MoviesQueriesItemCell: UITableViewCell { 4 | static let height = CGFloat(50) 5 | static let reuseIdentifier = String(describing: MoviesQueriesItemCell.self) 6 | 7 | @IBOutlet private var titleLabel: UILabel! 8 | 9 | func fill(with suggestion: MoviesQueryListItemViewModel) { 10 | self.titleLabel.text = suggestion.query 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/View/UIKit/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 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/View/UIKit/MoviesQueriesTableViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final class MoviesQueriesTableViewController: UITableViewController, StoryboardInstantiable { 4 | 5 | private var viewModel: MoviesQueryListViewModel! 6 | 7 | // MARK: - Lifecycle 8 | 9 | static func create(with viewModel: MoviesQueryListViewModel) -> MoviesQueriesTableViewController { 10 | let view = MoviesQueriesTableViewController.instantiateViewController() 11 | view.viewModel = viewModel 12 | return view 13 | } 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | setupViews() 18 | bind(to: viewModel) 19 | } 20 | 21 | private func bind(to viewModel: MoviesQueryListViewModel) { 22 | viewModel.items.observe(on: self) { [weak self] _ in self?.tableView.reloadData() } 23 | } 24 | 25 | override func viewWillAppear(_ animated: Bool) { 26 | super.viewWillAppear(animated) 27 | 28 | viewModel.viewWillAppear() 29 | } 30 | 31 | // MARK: - Private 32 | 33 | private func setupViews() { 34 | tableView.tableFooterView = UIView() 35 | tableView.backgroundColor = .clear 36 | tableView.estimatedRowHeight = MoviesQueriesItemCell.height 37 | tableView.rowHeight = UITableView.automaticDimension 38 | } 39 | } 40 | 41 | // MARK: - UITableViewDataSource, UITableViewDelegate 42 | 43 | extension MoviesQueriesTableViewController { 44 | 45 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 46 | return viewModel.items.value.count 47 | } 48 | 49 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 50 | guard let cell = tableView.dequeueReusableCell(withIdentifier: MoviesQueriesItemCell.reuseIdentifier, for: indexPath) as? MoviesQueriesItemCell else { 51 | assertionFailure("Cannot dequeue reusable cell \(MoviesQueriesItemCell.self) with reuseIdentifier: \(MoviesQueriesItemCell.reuseIdentifier)") 52 | return UITableViewCell() 53 | } 54 | cell.fill(with: viewModel.items.value[indexPath.row]) 55 | 56 | return cell 57 | } 58 | 59 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 60 | tableView.deselectRow(at: indexPath, animated: false) 61 | viewModel.didSelect(item: viewModel.items.value[indexPath.row]) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/ViewModel/MoviesQueryListItemViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class MoviesQueryListItemViewModel { 4 | let query: String 5 | 6 | init(query: String) { 7 | self.query = query 8 | } 9 | } 10 | 11 | extension MoviesQueryListItemViewModel: Equatable { 12 | static func == (lhs: MoviesQueryListItemViewModel, rhs: MoviesQueryListItemViewModel) -> Bool { 13 | return lhs.query == rhs.query 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/ViewModel/MoviesQueryListViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | typealias MoviesQueryListViewModelDidSelectAction = (MovieQuery) -> Void 4 | 5 | protocol MoviesQueryListViewModelInput { 6 | func viewWillAppear() 7 | func didSelect(item: MoviesQueryListItemViewModel) 8 | } 9 | 10 | protocol MoviesQueryListViewModelOutput { 11 | var items: Observable<[MoviesQueryListItemViewModel]> { get } 12 | } 13 | 14 | protocol MoviesQueryListViewModel: MoviesQueryListViewModelInput, MoviesQueryListViewModelOutput { } 15 | 16 | typealias FetchRecentMovieQueriesUseCaseFactory = ( 17 | FetchRecentMovieQueriesUseCase.RequestValue, 18 | @escaping (FetchRecentMovieQueriesUseCase.ResultValue) -> Void 19 | ) -> UseCase 20 | 21 | final class DefaultMoviesQueryListViewModel: MoviesQueryListViewModel { 22 | 23 | private let numberOfQueriesToShow: Int 24 | private let fetchRecentMovieQueriesUseCaseFactory: FetchRecentMovieQueriesUseCaseFactory 25 | private let didSelect: MoviesQueryListViewModelDidSelectAction? 26 | private let mainQueue: DispatchQueueType 27 | 28 | // MARK: - OUTPUT 29 | let items: Observable<[MoviesQueryListItemViewModel]> = Observable([]) 30 | 31 | init( 32 | numberOfQueriesToShow: Int, 33 | fetchRecentMovieQueriesUseCaseFactory: @escaping FetchRecentMovieQueriesUseCaseFactory, 34 | didSelect: MoviesQueryListViewModelDidSelectAction? = nil, 35 | mainQueue: DispatchQueueType = DispatchQueue.main 36 | ) { 37 | self.numberOfQueriesToShow = numberOfQueriesToShow 38 | self.fetchRecentMovieQueriesUseCaseFactory = fetchRecentMovieQueriesUseCaseFactory 39 | self.didSelect = didSelect 40 | self.mainQueue = mainQueue 41 | } 42 | 43 | private func updateMoviesQueries() { 44 | let request = FetchRecentMovieQueriesUseCase.RequestValue(maxCount: numberOfQueriesToShow) 45 | let completion: (FetchRecentMovieQueriesUseCase.ResultValue) -> Void = { [weak self] result in 46 | self?.mainQueue.async { 47 | switch result { 48 | case .success(let items): 49 | self?.items.value = items 50 | .map { $0.query } 51 | .map(MoviesQueryListItemViewModel.init) 52 | case .failure: 53 | break 54 | } 55 | } 56 | } 57 | let useCase = fetchRecentMovieQueriesUseCaseFactory(request, completion) 58 | useCase.start() 59 | } 60 | } 61 | 62 | // MARK: - INPUT. View event methods 63 | extension DefaultMoviesQueryListViewModel { 64 | 65 | func viewWillAppear() { 66 | updateMoviesQueries() 67 | } 68 | 69 | func didSelect(item: MoviesQueryListItemViewModel) { 70 | didSelect?(MovieQuery(query: item.query)) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/AccessibilityIdentifier.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AccessibilityIdentifier { 4 | static let movieDetailsView = "AccessibilityIdentifierMovieDetailsView" 5 | static let searchField = "AccessibilityIdentifierSearchMovies" 6 | } 7 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Extensions/CGSize+ScaledSize.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | extension CGSize { 5 | var scaledSize: CGSize { 6 | .init(width: width * UIScreen.main.scale, height: height * UIScreen.main.scale) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Extensions/DataTransferError+ConnectionError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension DataTransferError: ConnectionError { 4 | var isInternetConnectionError: Bool { 5 | guard case let DataTransferError.networkFailure(networkError) = self, 6 | case .notConnected = networkError else { 7 | return false 8 | } 9 | return true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Extensions/UIImageView+ImageSizeAfterAspectFit.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | extension UIImageView { 5 | 6 | var imageSizeAfterAspectFit: CGSize { 7 | var newWidth: CGFloat 8 | var newHeight: CGFloat 9 | 10 | guard let image = image else { return frame.size } 11 | 12 | if image.size.height >= image.size.width { 13 | newHeight = frame.size.height 14 | newWidth = ((image.size.width / (image.size.height)) * newHeight) 15 | 16 | if CGFloat(newWidth) > (frame.size.width) { 17 | let diff = (frame.size.width) - newWidth 18 | newHeight = newHeight + CGFloat(diff) / newHeight * newHeight 19 | newWidth = frame.size.width 20 | } 21 | } else { 22 | newWidth = frame.size.width 23 | newHeight = (image.size.height / image.size.width) * newWidth 24 | 25 | if newHeight > frame.size.height { 26 | let diff = Float((frame.size.height) - newHeight) 27 | newWidth = newWidth + CGFloat(diff) / newWidth * newWidth 28 | newHeight = frame.size.height 29 | } 30 | } 31 | return .init(width: newWidth, height: newHeight) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Extensions/UIViewController+ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UITableViewController { 4 | 5 | func makeActivityIndicator(size: CGSize) -> UIActivityIndicatorView { 6 | let style: UIActivityIndicatorView.Style 7 | if #available(iOS 12.0, *) { 8 | if self.traitCollection.userInterfaceStyle == .dark { 9 | style = .white 10 | } else { 11 | style = .gray 12 | } 13 | } else { 14 | style = .gray 15 | } 16 | 17 | let activityIndicator = UIActivityIndicatorView(style: style) 18 | activityIndicator.startAnimating() 19 | activityIndicator.isHidden = false 20 | activityIndicator.frame = .init(origin: .zero, size: size) 21 | 22 | return activityIndicator 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Extensions/UIViewController+AddBehaviors.swift: -------------------------------------------------------------------------------- 1 | // View controller lifecycle behaviors https://irace.me/lifecycle-behaviors 2 | // Behaviors are very useful to reuse logic for cases like Keyboard Behaviour. 3 | // Where ViewController on didLoad adds behaviour which observes keyboard frame 4 | // and scrollView content inset changes based on keyboard frame. 5 | 6 | import UIKit 7 | 8 | protocol ViewControllerLifecycleBehavior { 9 | func viewDidLoad(viewController: UIViewController) 10 | func viewWillAppear(viewController: UIViewController) 11 | func viewDidAppear(viewController: UIViewController) 12 | func viewWillDisappear(viewController: UIViewController) 13 | func viewDidDisappear(viewController: UIViewController) 14 | func viewWillLayoutSubviews(viewController: UIViewController) 15 | func viewDidLayoutSubviews(viewController: UIViewController) 16 | } 17 | // Default implementations 18 | extension ViewControllerLifecycleBehavior { 19 | func viewDidLoad(viewController: UIViewController) {} 20 | func viewWillAppear(viewController: UIViewController) {} 21 | func viewDidAppear(viewController: UIViewController) {} 22 | func viewWillDisappear(viewController: UIViewController) {} 23 | func viewDidDisappear(viewController: UIViewController) {} 24 | func viewWillLayoutSubviews(viewController: UIViewController) {} 25 | func viewDidLayoutSubviews(viewController: UIViewController) {} 26 | } 27 | 28 | extension UIViewController { 29 | /* 30 | Add behaviors to be hooked into this view controller’s lifecycle. 31 | 32 | This method requires the view controller’s view to be loaded, so it’s best to call 33 | in `viewDidLoad` to avoid it being loaded prematurely. 34 | 35 | - parameter behaviors: Behaviors to be added. 36 | */ 37 | func addBehaviors(_ behaviors: [ViewControllerLifecycleBehavior]) { 38 | let behaviorViewController = LifecycleBehaviorViewController(behaviors: behaviors) 39 | 40 | addChild(behaviorViewController) 41 | view.addSubview(behaviorViewController.view) 42 | behaviorViewController.didMove(toParent: self) 43 | } 44 | 45 | private final class LifecycleBehaviorViewController: UIViewController, UIGestureRecognizerDelegate { 46 | private let behaviors: [ViewControllerLifecycleBehavior] 47 | 48 | // MARK: - Lifecycle 49 | 50 | init(behaviors: [ViewControllerLifecycleBehavior]) { 51 | self.behaviors = behaviors 52 | 53 | super.init(nibName: nil, bundle: nil) 54 | } 55 | 56 | required init?(coder aDecoder: NSCoder) { 57 | fatalError("init(coder:) has not been implemented") 58 | } 59 | 60 | override func viewDidLoad() { 61 | super.viewDidLoad() 62 | 63 | view.isHidden = true 64 | 65 | applyBehaviors { behavior, viewController in 66 | behavior.viewDidLoad(viewController: viewController) 67 | } 68 | } 69 | 70 | override func viewWillAppear(_ animated: Bool) { 71 | super.viewWillAppear(animated) 72 | 73 | applyBehaviors { behavior, viewController in 74 | behavior.viewWillAppear(viewController: viewController) 75 | } 76 | } 77 | 78 | override func viewDidAppear(_ animated: Bool) { 79 | super.viewDidAppear(animated) 80 | 81 | applyBehaviors { behavior, viewController in 82 | behavior.viewDidAppear(viewController: viewController) 83 | } 84 | } 85 | 86 | override func viewWillDisappear(_ animated: Bool) { 87 | super.viewWillDisappear(animated) 88 | 89 | applyBehaviors { behavior, viewController in 90 | behavior.viewWillDisappear(viewController: viewController) 91 | } 92 | } 93 | 94 | override func viewDidDisappear(_ animated: Bool) { 95 | super.viewDidDisappear(animated) 96 | 97 | applyBehaviors { behavior, viewController in 98 | behavior.viewDidDisappear(viewController: viewController) 99 | } 100 | } 101 | 102 | override func viewWillLayoutSubviews() { 103 | super.viewWillLayoutSubviews() 104 | 105 | applyBehaviors { behavior, viewController in 106 | behavior.viewWillLayoutSubviews(viewController: viewController) 107 | } 108 | } 109 | 110 | override func viewDidLayoutSubviews() { 111 | super.viewDidLayoutSubviews() 112 | 113 | applyBehaviors { behavior, viewController in 114 | behavior.viewDidLayoutSubviews(viewController: viewController) 115 | } 116 | } 117 | 118 | // MARK: - Private 119 | 120 | private func applyBehaviors(body: (_ behavior: ViewControllerLifecycleBehavior, _ viewController: UIViewController) -> Void) { 121 | guard let parent = parent else { return } 122 | 123 | for behavior in behaviors { 124 | body(behavior, parent) 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Extensions/UIViewController+AddChild.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | extension UIViewController { 4 | 5 | func add(child: UIViewController, container: UIView) { 6 | addChild(child) 7 | child.view.frame = container.bounds 8 | container.addSubview(child.view) 9 | child.didMove(toParent: self) 10 | } 11 | 12 | func remove() { 13 | guard parent != nil else { 14 | return 15 | } 16 | willMove(toParent: nil) 17 | removeFromParent() 18 | view.removeFromSuperview() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/LoadingView.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | class LoadingView { 4 | 5 | internal static var spinner: UIActivityIndicatorView? 6 | 7 | static func show() { 8 | DispatchQueue.main.async { 9 | NotificationCenter.default.addObserver(self, selector: #selector(update), name: UIDevice.orientationDidChangeNotification, object: nil) 10 | if spinner == nil, let window = UIApplication.shared.keyWindow { 11 | let frame = UIScreen.main.bounds 12 | let spinner = UIActivityIndicatorView(frame: frame) 13 | spinner.backgroundColor = UIColor.black.withAlphaComponent(0.2) 14 | spinner.style = .whiteLarge 15 | window.addSubview(spinner) 16 | 17 | spinner.startAnimating() 18 | self.spinner = spinner 19 | } 20 | } 21 | } 22 | 23 | static func hide() { 24 | DispatchQueue.main.async { 25 | guard let spinner = spinner else { return } 26 | spinner.stopAnimating() 27 | spinner.removeFromSuperview() 28 | self.spinner = nil 29 | } 30 | } 31 | 32 | @objc static func update() { 33 | DispatchQueue.main.async { 34 | if spinner != nil { 35 | hide() 36 | show() 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Observable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class Observable { 4 | 5 | struct Observer { 6 | weak var observer: AnyObject? 7 | let block: (Value) -> Void 8 | } 9 | 10 | private var observers = [Observer]() 11 | 12 | var value: Value { 13 | didSet { notifyObservers() } 14 | } 15 | 16 | init(_ value: Value) { 17 | self.value = value 18 | } 19 | 20 | func observe(on observer: AnyObject, observerBlock: @escaping (Value) -> Void) { 21 | observers.append(Observer(observer: observer, block: observerBlock)) 22 | observerBlock(self.value) 23 | } 24 | 25 | func remove(observer: AnyObject) { 26 | observers = observers.filter { $0.observer !== observer } 27 | } 28 | 29 | private func notifyObservers() { 30 | for observer in observers { 31 | observer.block(self.value) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Protocols/Alertable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol Alertable {} 4 | extension Alertable where Self: UIViewController { 5 | 6 | func showAlert( 7 | title: String = "", 8 | message: String, 9 | preferredStyle: UIAlertController.Style = .alert, 10 | completion: (() -> Void)? = nil 11 | ) { 12 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 13 | alert.addAction(UIAlertAction(title: "OK", style: UIAlertAction.Style.default, handler: nil)) 14 | self.present(alert, animated: true, completion: completion) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /ExampleMVVM/Presentation/Utils/Protocols/StoryboardInstantiable.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | protocol StoryboardInstantiable: NSObjectProtocol { 4 | associatedtype T 5 | static var defaultFileName: String { get } 6 | static func instantiateViewController(_ bundle: Bundle?) -> T 7 | } 8 | 9 | extension StoryboardInstantiable where Self: UIViewController { 10 | static var defaultFileName: String { 11 | return NSStringFromClass(Self.self).components(separatedBy: ".").last! 12 | } 13 | 14 | static func instantiateViewController(_ bundle: Bundle? = nil) -> Self { 15 | let fileName = defaultFileName 16 | let storyboard = UIStoryboard(name: fileName, bundle: bundle) 17 | guard let vc = storyboard.instantiateInitialViewController() as? Self else { 18 | 19 | fatalError("Cannot instantiate initial view controller \(Self.self) from storyboard with name \(fileName)") 20 | } 21 | return vc 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /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/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 | 34 | UILaunchStoryboardName 35 | LaunchScreen 36 | UIRequiredDeviceCapabilities 37 | 38 | armv7 39 | 40 | UIStatusBarStyle 41 | UIStatusBarStyleLightContent 42 | UISupportedInterfaceOrientations 43 | 44 | UIInterfaceOrientationPortrait 45 | UIInterfaceOrientationLandscapeLeft 46 | UIInterfaceOrientationLandscapeRight 47 | 48 | UISupportedInterfaceOrientations~ipad 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationPortraitUpsideDown 52 | UIInterfaceOrientationLandscapeLeft 53 | UIInterfaceOrientationLandscapeRight 54 | 55 | UIViewControllerBasedStatusBarAppearance 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /ExampleMVVM/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | App 4 | 5 | Created by Oleh Kudinov on 01.12.19. 6 | 7 | */ 8 | 9 | "Movies" = "Movies"; 10 | "Search results" = "Search results"; 11 | "Release Date" = "Release Date"; 12 | "To be announced" = "To be announced"; 13 | "Error" = "Error"; 14 | "No internet connection" = "No internet connection"; 15 | "Failed loading movies" = "Failed loading movies"; 16 | "Search Movies" = "Search Movies"; 17 | -------------------------------------------------------------------------------- /ExampleMVVM/Resources/es.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ExampleMVVM/Resources/es.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | App 4 | 5 | Created by Oleh Kudinov on 01.12.19. 6 | 7 | */ 8 | 9 | "Movies" = "Películas"; 10 | "Search results" = "Resultados de búsqueda"; 11 | "Release Date" = "Fecha de lanzamiento"; 12 | "To be announced" = "Para ser anunciado"; 13 | "Error" = "Error"; 14 | "No internet connection" = "Sin conexión a internet"; 15 | "Failed loading movies" = "Error al descargar películas"; 16 | "Search Movies" = "Buscar películas"; 17 | -------------------------------------------------------------------------------- /ExampleMVVM/Stubs/Movie+Stub.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Movie { 4 | static func stub(id: Movie.Identifier = "id1", 5 | title: String = "title1" , 6 | genre: Movie.Genre = .adventure, 7 | posterPath: String? = "/1", 8 | overview: String = "overview1", 9 | releaseDate: Date? = nil) -> Self { 10 | Movie(id: id, 11 | title: title, 12 | genre: genre, 13 | posterPath: posterPath, 14 | overview: overview, 15 | releaseDate: releaseDate) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Domain/UseCases/SearchMoviesUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class SearchMoviesUseCaseTests: XCTestCase { 4 | 5 | static let moviesPages: [MoviesPage] = { 6 | let page1 = MoviesPage(page: 1, totalPages: 2, movies: [ 7 | Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"), 8 | Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2")]) 9 | let page2 = MoviesPage(page: 2, totalPages: 2, movies: [ 10 | Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")]) 11 | return [page1, page2] 12 | }() 13 | 14 | enum MoviesRepositorySuccessTestError: Error { 15 | case failedFetching 16 | } 17 | 18 | class MoviesQueriesRepositoryMock: MoviesQueriesRepository { 19 | var recentQueries: [MovieQuery] = [] 20 | var fetchCompletionCallsCount = 0 21 | 22 | func fetchRecentsQueries( 23 | maxCount: Int, 24 | completion: @escaping (Result<[MovieQuery], Error>) -> Void 25 | ) { 26 | completion(.success(recentQueries)) 27 | fetchCompletionCallsCount += 1 28 | } 29 | func saveRecentQuery(query: MovieQuery, completion: @escaping (Result) -> Void) { 30 | recentQueries.append(query) 31 | } 32 | } 33 | 34 | class MoviesRepositoryMock: MoviesRepository { 35 | var result: Result 36 | var fetchCompletionCallsCount = 0 37 | 38 | init(result: Result) { 39 | self.result = result 40 | } 41 | 42 | func fetchMoviesList( 43 | query: MovieQuery, 44 | page: Int, 45 | cached: @escaping (MoviesPage) -> Void, 46 | completion: @escaping (Result 47 | ) -> Void 48 | ) -> Cancellable? { 49 | completion(result) 50 | fetchCompletionCallsCount += 1 51 | return nil 52 | } 53 | } 54 | 55 | func testSearchMoviesUseCase_whenSuccessfullyFetchesMoviesForQuery_thenQueryIsSavedInRecentQueries() { 56 | // given 57 | var useCaseCompletionCallsCount = 0 58 | let moviesQueriesRepository = MoviesQueriesRepositoryMock() 59 | let moviesRepository = MoviesRepositoryMock( 60 | result: .success(SearchMoviesUseCaseTests.moviesPages[0]) 61 | ) 62 | let useCase = DefaultSearchMoviesUseCase( 63 | moviesRepository: moviesRepository, 64 | moviesQueriesRepository: moviesQueriesRepository 65 | ) 66 | 67 | // when 68 | let requestValue = SearchMoviesUseCaseRequestValue( 69 | query: MovieQuery(query: "title1"), 70 | page: 0 71 | ) 72 | _ = useCase.execute( 73 | requestValue: requestValue, 74 | cached: { _ in } 75 | ) { _ in 76 | useCaseCompletionCallsCount += 1 77 | } 78 | // then 79 | var recents = [MovieQuery]() 80 | moviesQueriesRepository.fetchRecentsQueries(maxCount: 1) { result in 81 | recents = (try? result.get()) ?? [] 82 | } 83 | XCTAssertTrue(recents.contains(MovieQuery(query: "title1"))) 84 | XCTAssertEqual(useCaseCompletionCallsCount, 1) 85 | XCTAssertEqual(moviesQueriesRepository.fetchCompletionCallsCount, 1) 86 | XCTAssertEqual(moviesRepository.fetchCompletionCallsCount, 1) 87 | } 88 | 89 | func testSearchMoviesUseCase_whenFailedFetchingMoviesForQuery_thenQueryIsNotSavedInRecentQueries() { 90 | // given 91 | var useCaseCompletionCallsCountCount = 0 92 | let moviesQueriesRepository = MoviesQueriesRepositoryMock() 93 | let useCase = DefaultSearchMoviesUseCase(moviesRepository: MoviesRepositoryMock(result: .failure(MoviesRepositorySuccessTestError.failedFetching)), 94 | moviesQueriesRepository: moviesQueriesRepository) 95 | 96 | // when 97 | let requestValue = SearchMoviesUseCaseRequestValue(query: MovieQuery(query: "title1"), 98 | page: 0) 99 | _ = useCase.execute(requestValue: requestValue, cached: { _ in }) { _ in 100 | useCaseCompletionCallsCountCount += 1 101 | } 102 | // then 103 | var recents = [MovieQuery]() 104 | moviesQueriesRepository.fetchRecentsQueries(maxCount: 1) { result in 105 | recents = (try? result.get()) ?? [] 106 | } 107 | XCTAssertTrue(recents.isEmpty) 108 | XCTAssertEqual(useCaseCompletionCallsCountCount, 1) 109 | XCTAssertEqual(moviesQueriesRepository.fetchCompletionCallsCount, 1) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Infrastructure/Network/DataTransferServiceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | private struct MockModel: Decodable { 4 | let name: String 5 | } 6 | 7 | final class DataTransferDispatchQueueMock: DataTransferDispatchQueue { 8 | func asyncExecute(work: @escaping () -> Void) { 9 | work() 10 | } 11 | } 12 | 13 | class DataTransferServiceTests: XCTestCase { 14 | 15 | private enum DataTransferErrorMock: Error { 16 | case someError 17 | } 18 | 19 | func test_whenReceivedValidJsonInResponse_shouldDecodeResponseToDecodableObject() { 20 | //given 21 | let config = NetworkConfigurableMock() 22 | var completionCallsCount = 0 23 | 24 | let responseData = #"{"name": "Hello"}"#.data(using: .utf8) 25 | let networkService = DefaultNetworkService( 26 | config: config, 27 | sessionManager: NetworkSessionManagerMock( 28 | response: nil, 29 | data: responseData, 30 | error: nil 31 | ) 32 | ) 33 | 34 | let sut = DefaultDataTransferService(with: networkService) 35 | //when 36 | _ = sut.request( 37 | with: Endpoint(path: "http://mock.endpoint.com", method: .get), 38 | on: DataTransferDispatchQueueMock() 39 | ) { result in 40 | do { 41 | let object = try result.get() 42 | XCTAssertEqual(object.name, "Hello") 43 | completionCallsCount += 1 44 | } catch { 45 | XCTFail("Failed decoding MockObject") 46 | } 47 | } 48 | //then 49 | XCTAssertEqual(completionCallsCount, 1) 50 | } 51 | 52 | func test_whenInvalidResponse_shouldNotDecodeObject() { 53 | //given 54 | let config = NetworkConfigurableMock() 55 | var completionCallsCount = 0 56 | 57 | let responseData = #"{"age": 20}"#.data(using: .utf8) 58 | let networkService = DefaultNetworkService( 59 | config: config, 60 | sessionManager: NetworkSessionManagerMock( 61 | response: nil, 62 | data: responseData, 63 | error: nil 64 | ) 65 | ) 66 | 67 | let sut = DefaultDataTransferService(with: networkService) 68 | //when 69 | _ = sut.request( 70 | with: Endpoint(path: "http://mock.endpoint.com", method: .get), 71 | on: DataTransferDispatchQueueMock() 72 | ) { result in 73 | do { 74 | _ = try result.get() 75 | XCTFail("Should not happen") 76 | } catch { 77 | completionCallsCount += 1 78 | } 79 | } 80 | //then 81 | XCTAssertEqual(completionCallsCount, 1) 82 | } 83 | 84 | func test_whenBadRequestReceived_shouldRethrowNetworkError() { 85 | //given 86 | let config = NetworkConfigurableMock() 87 | var completionCallsCount = 0 88 | 89 | let responseData = #"{"invalidStructure": "Nothing"}"#.data(using: .utf8)! 90 | let response = HTTPURLResponse(url: URL(string: "test_url")!, 91 | statusCode: 500, 92 | httpVersion: "1.1", 93 | headerFields: nil) 94 | let networkService = DefaultNetworkService( 95 | config: config, 96 | sessionManager: NetworkSessionManagerMock( 97 | response: response, 98 | data: responseData, 99 | error: DataTransferErrorMock.someError 100 | ) 101 | ) 102 | 103 | let sut = DefaultDataTransferService(with: networkService) 104 | //when 105 | _ = sut.request( 106 | with: Endpoint(path: "http://mock.endpoint.com", method: .get), 107 | on: DataTransferDispatchQueueMock() 108 | ) { result in 109 | do { 110 | _ = try result.get() 111 | XCTFail("Should not happen") 112 | } catch let error { 113 | 114 | if case DataTransferError.networkFailure(NetworkError.error(statusCode: 500, _)) = error { 115 | completionCallsCount += 1 116 | } else { 117 | XCTFail("Wrong error") 118 | } 119 | } 120 | } 121 | //then 122 | XCTAssertEqual(completionCallsCount, 1) 123 | } 124 | 125 | func test_whenNoDataReceived_shouldThrowNoDataError() { 126 | //given 127 | let config = NetworkConfigurableMock() 128 | var completionCallsCount = 0 129 | 130 | let response = HTTPURLResponse(url: URL(string: "test_url")!, 131 | statusCode: 200, 132 | httpVersion: "1.1", 133 | headerFields: [:]) 134 | let networkService = DefaultNetworkService( 135 | config: config, 136 | sessionManager: NetworkSessionManagerMock( 137 | response: response, 138 | data: nil, 139 | error: nil 140 | ) 141 | ) 142 | 143 | let sut = DefaultDataTransferService(with: networkService) 144 | //when 145 | _ = sut.request( 146 | with: Endpoint(path: "http://mock.endpoint.com", method: .get), 147 | on: DataTransferDispatchQueueMock() 148 | ) { result in 149 | do { 150 | _ = try result.get() 151 | XCTFail("Should not happen") 152 | } catch let error { 153 | if case DataTransferError.noResponse = error { 154 | completionCallsCount += 1 155 | } else { 156 | XCTFail("Wrong error") 157 | } 158 | } 159 | } 160 | //then 161 | XCTAssertEqual(completionCallsCount, 1) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Infrastructure/Network/Mocks/NetworkConfigurableMock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class NetworkConfigurableMock: NetworkConfigurable { 4 | var baseURL: URL = URL(string: "https://mock.test.com")! 5 | var headers: [String: String] = [:] 6 | var queryParameters: [String: String] = [:] 7 | } 8 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Infrastructure/Network/Mocks/NetworkSessionManagerMock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct NetworkSessionManagerMock: NetworkSessionManager { 4 | let response: HTTPURLResponse? 5 | let data: Data? 6 | let error: Error? 7 | 8 | func request(_ request: URLRequest, 9 | completion: @escaping CompletionHandler) -> NetworkCancellable { 10 | completion(data, response, error) 11 | return URLSessionDataTask() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Infrastructure/Network/NetworkServiceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class NetworkServiceTests: XCTestCase { 4 | 5 | private struct EndpointMock: Requestable { 6 | var path: String 7 | var isFullPath: Bool = false 8 | var method: HTTPMethodType 9 | var headerParameters: [String: String] = [:] 10 | var queryParametersEncodable: Encodable? 11 | var queryParameters: [String: Any] = [:] 12 | var bodyParametersEncodable: Encodable? 13 | var bodyParameters: [String: Any] = [:] 14 | var bodyEncoder: BodyEncoder = AsciiBodyEncoder() 15 | 16 | init(path: String, method: HTTPMethodType) { 17 | self.path = path 18 | self.method = method 19 | } 20 | } 21 | 22 | class NetworkErrorLoggerMock: NetworkErrorLogger { 23 | var loggedErrors: [Error] = [] 24 | func log(request: URLRequest) { } 25 | func log(responseData data: Data?, response: URLResponse?) { } 26 | func log(error: Error) { loggedErrors.append(error) } 27 | } 28 | 29 | private enum NetworkErrorMock: Error { 30 | case someError 31 | } 32 | 33 | func test_whenMockDataPassed_shouldReturnProperResponse() { 34 | //given 35 | let config = NetworkConfigurableMock() 36 | var completionCallsCount = 0 37 | 38 | let expectedResponseData = "Response data".data(using: .utf8)! 39 | let sut = DefaultNetworkService( 40 | config: config, 41 | sessionManager: NetworkSessionManagerMock( 42 | response: nil, 43 | data: expectedResponseData, 44 | error: nil 45 | ) 46 | ) 47 | //when 48 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 49 | guard let responseData = try? result.get() else { 50 | XCTFail("Should return proper response") 51 | return 52 | } 53 | XCTAssertEqual(responseData, expectedResponseData) 54 | completionCallsCount += 1 55 | } 56 | //then 57 | XCTAssertEqual(completionCallsCount, 1) 58 | } 59 | 60 | func test_whenErrorWithNSURLErrorCancelledReturned_shouldReturnCancelledError() { 61 | //given 62 | let config = NetworkConfigurableMock() 63 | var completionCallsCount = 0 64 | 65 | let cancelledError = NSError(domain: "network", code: NSURLErrorCancelled, userInfo: nil) 66 | let sut = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: nil, 67 | data: nil, 68 | error: cancelledError as Error)) 69 | //when 70 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 71 | do { 72 | _ = try result.get() 73 | XCTFail("Should not happen") 74 | } catch let error { 75 | guard case NetworkError.cancelled = error else { 76 | XCTFail("NetworkError.cancelled not found") 77 | return 78 | } 79 | 80 | completionCallsCount += 1 81 | } 82 | } 83 | //then 84 | XCTAssertEqual(completionCallsCount, 1) 85 | } 86 | 87 | func test_whenStatusCodeEqualOrAbove400_shouldReturnhasStatusCodeError() { 88 | //given 89 | let config = NetworkConfigurableMock() 90 | var completionCallsCount = 0 91 | 92 | let response = HTTPURLResponse(url: URL(string: "test_url")!, 93 | statusCode: 500, 94 | httpVersion: "1.1", 95 | headerFields: [:]) 96 | let sut = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: response, 97 | data: nil, 98 | error: NetworkErrorMock.someError)) 99 | //when 100 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 101 | do { 102 | _ = try result.get() 103 | XCTFail("Should not happen") 104 | } catch let error { 105 | if case NetworkError.error(let statusCode, _) = error { 106 | XCTAssertEqual(statusCode, 500) 107 | completionCallsCount += 1 108 | } 109 | } 110 | } 111 | //then 112 | XCTAssertEqual(completionCallsCount, 1) 113 | } 114 | 115 | func test_whenErrorWithNSURLErrorNotConnectedToInternetReturned_shouldReturnNotConnectedError() { 116 | //given 117 | let config = NetworkConfigurableMock() 118 | var completionCallsCount = 0 119 | 120 | let error = NSError(domain: "network", code: NSURLErrorNotConnectedToInternet, userInfo: nil) 121 | let sut = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: nil, 122 | data: nil, 123 | error: error as Error)) 124 | 125 | //when 126 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 127 | do { 128 | _ = try result.get() 129 | XCTFail("Should not happen") 130 | } catch let error { 131 | guard case NetworkError.notConnected = error else { 132 | XCTFail("NetworkError.notConnected not found") 133 | return 134 | } 135 | 136 | completionCallsCount += 1 137 | } 138 | } 139 | //then 140 | XCTAssertEqual(completionCallsCount, 1) 141 | } 142 | 143 | func test_whenhasStatusCodeUsedWithWrongError_shouldReturnFalse() { 144 | //when 145 | let sut = NetworkError.notConnected 146 | //then 147 | XCTAssertFalse(sut.hasStatusCode(200)) 148 | } 149 | 150 | func test_whenhasStatusCodeUsed_shouldReturnCorrectStatusCode_() { 151 | //when 152 | let sut = NetworkError.error(statusCode: 400, data: nil) 153 | //then 154 | XCTAssertTrue(sut.hasStatusCode(400)) 155 | XCTAssertFalse(sut.hasStatusCode(399)) 156 | XCTAssertFalse(sut.hasStatusCode(401)) 157 | } 158 | 159 | func test_whenErrorWithNSURLErrorNotConnectedToInternetReturned_shouldLogThisError() { 160 | //given 161 | let config = NetworkConfigurableMock() 162 | var completionCallsCount = 0 163 | 164 | let error = NSError(domain: "network", code: NSURLErrorNotConnectedToInternet, userInfo: nil) 165 | let networkErrorLogger = NetworkErrorLoggerMock() 166 | let sut = DefaultNetworkService(config: config, sessionManager: NetworkSessionManagerMock(response: nil, 167 | data: nil, 168 | error: error as Error), 169 | logger: networkErrorLogger) 170 | //when 171 | _ = sut.request(endpoint: EndpointMock(path: "http://mock.test.com", method: .get)) { result in 172 | do { 173 | _ = try result.get() 174 | XCTFail("Should not happen") 175 | } catch let error { 176 | guard case NetworkError.notConnected = error else { 177 | XCTFail("NetworkError.notConnected not found") 178 | return 179 | } 180 | 181 | completionCallsCount += 1 182 | } 183 | } 184 | 185 | //then 186 | XCTAssertEqual(completionCallsCount, 1) 187 | XCTAssertTrue(networkErrorLogger.loggedErrors.contains { 188 | guard case NetworkError.notConnected = $0 else { return false } 189 | return true 190 | }) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Presentation/MoviesScene/Mocks/PosterImagesRepositoryMock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | class PosterImagesRepositoryMock: PosterImagesRepository { 5 | var completionCalls = 0 6 | var error: Error? 7 | var image = Data() 8 | var validateInput: ((String, Int) -> Void)? 9 | 10 | func fetchImage(with imagePath: String, width: Int, completion: @escaping (Result) -> Void) -> Cancellable? { 11 | validateInput?(imagePath, width) 12 | if let error = error { 13 | completion(.failure(error)) 14 | } else { 15 | completion(.success(image)) 16 | } 17 | completionCalls += 1 18 | return nil 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Presentation/MoviesScene/MovieDetailsViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class MovieDetailsViewModelTests: XCTestCase { 4 | 5 | private enum PosterImageDownloadError: Error { 6 | case someError 7 | } 8 | 9 | func test_updatePosterImageWithWidthEventReceived_thenImageWithThisWidthIsDownloaded() { 10 | // given 11 | let posterImagesRepository = PosterImagesRepositoryMock() 12 | 13 | let expectedImage = "image data".data(using: .utf8)! 14 | posterImagesRepository.image = expectedImage 15 | 16 | let viewModel = DefaultMovieDetailsViewModel( 17 | movie: Movie.stub(posterPath: "posterPath"), 18 | posterImagesRepository: posterImagesRepository, 19 | mainQueue: DispatchQueueTypeMock() 20 | ) 21 | 22 | posterImagesRepository.validateInput = { (imagePath: String, width: Int) in 23 | XCTAssertEqual(imagePath, "posterPath") 24 | XCTAssertEqual(width, 200) 25 | } 26 | 27 | // when 28 | viewModel.updatePosterImage(width: 200) 29 | 30 | // then 31 | XCTAssertEqual(viewModel.posterImage.value, expectedImage) 32 | XCTAssertEqual(posterImagesRepository.completionCalls, 1) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Presentation/MoviesScene/MoviesListViewModelTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class MoviesListViewModelTests: XCTestCase { 4 | 5 | private enum SearchMoviesUseCaseError: Error { 6 | case someError 7 | } 8 | 9 | let moviesPages: [MoviesPage] = { 10 | let page1 = MoviesPage(page: 1, totalPages: 2, movies: [ 11 | Movie.stub(id: "1", title: "title1", posterPath: "/1", overview: "overview1"), 12 | Movie.stub(id: "2", title: "title2", posterPath: "/2", overview: "overview2")]) 13 | let page2 = MoviesPage(page: 2, totalPages: 2, movies: [ 14 | Movie.stub(id: "3", title: "title3", posterPath: "/3", overview: "overview3")]) 15 | return [page1, page2] 16 | }() 17 | 18 | class SearchMoviesUseCaseMock: SearchMoviesUseCase { 19 | var executeCallCount: Int = 0 20 | 21 | typealias ExecuteBlock = ( 22 | SearchMoviesUseCaseRequestValue, 23 | (MoviesPage) -> Void, 24 | (Result) -> Void 25 | ) -> Void 26 | 27 | lazy var _execute: ExecuteBlock = { _, _, _ in 28 | XCTFail("not implemented") 29 | } 30 | 31 | func execute( 32 | requestValue: SearchMoviesUseCaseRequestValue, 33 | cached: @escaping (MoviesPage) -> Void, 34 | completion: @escaping (Result) -> Void 35 | ) -> Cancellable? { 36 | executeCallCount += 1 37 | _execute(requestValue, cached, completion) 38 | return nil 39 | } 40 | } 41 | 42 | func test_whenSearchMoviesUseCaseRetrievesEmptyPage_thenViewModelIsEmpty() { 43 | // given 44 | let searchMoviesUseCaseMock = SearchMoviesUseCaseMock() 45 | 46 | searchMoviesUseCaseMock._execute = { requestValue, _, completion in 47 | XCTAssertEqual(requestValue.page, 1) 48 | completion(.success(MoviesPage(page: 1, totalPages: 0, movies: []))) 49 | } 50 | let viewModel = DefaultMoviesListViewModel( 51 | searchMoviesUseCase: searchMoviesUseCaseMock, 52 | mainQueue: DispatchQueueTypeMock() 53 | ) 54 | // when 55 | viewModel.didSearch(query: "query") 56 | 57 | // then 58 | XCTAssertEqual(viewModel.currentPage, 1) 59 | XCTAssertFalse(viewModel.hasMorePages) 60 | XCTAssertTrue(viewModel.items.value.isEmpty) 61 | XCTAssertEqual(searchMoviesUseCaseMock.executeCallCount, 1) 62 | addTeardownBlock { [weak viewModel] in XCTAssertNil(viewModel) } 63 | } 64 | 65 | func test_whenSearchMoviesUseCaseRetrievesFirstPage_thenViewModelContainsOnlyFirstPage() { 66 | // given 67 | let searchMoviesUseCaseMock = SearchMoviesUseCaseMock() 68 | 69 | searchMoviesUseCaseMock._execute = { requestValue, _, completion in 70 | XCTAssertEqual(requestValue.page, 1) 71 | completion(.success(self.moviesPages[0])) 72 | } 73 | let viewModel = DefaultMoviesListViewModel( 74 | searchMoviesUseCase: searchMoviesUseCaseMock, 75 | mainQueue: DispatchQueueTypeMock() 76 | ) 77 | // when 78 | viewModel.didSearch(query: "query") 79 | 80 | // then 81 | let expectedItems = moviesPages[0] 82 | .movies 83 | .map { MoviesListItemViewModel(movie: $0) } 84 | XCTAssertEqual(viewModel.items.value, expectedItems) 85 | XCTAssertEqual(viewModel.currentPage, 1) 86 | XCTAssertTrue(viewModel.hasMorePages) 87 | XCTAssertEqual(searchMoviesUseCaseMock.executeCallCount, 1) 88 | addTeardownBlock { [weak viewModel] in XCTAssertNil(viewModel) } 89 | } 90 | 91 | func test_whenSearchMoviesUseCaseRetrievesFirstAndSecondPage_thenViewModelContainsTwoPages() { 92 | // given 93 | let searchMoviesUseCaseMock = SearchMoviesUseCaseMock() 94 | 95 | searchMoviesUseCaseMock._execute = { requestValue, _, completion in 96 | XCTAssertEqual(requestValue.page, 1) 97 | completion(.success(self.moviesPages[0])) 98 | } 99 | let viewModel = DefaultMoviesListViewModel.make( 100 | searchMoviesUseCase: searchMoviesUseCaseMock 101 | ) 102 | // when 103 | viewModel.didSearch(query: "query") 104 | XCTAssertEqual(searchMoviesUseCaseMock.executeCallCount, 1) 105 | 106 | searchMoviesUseCaseMock._execute = { requestValue, _, completion in 107 | XCTAssertEqual(requestValue.page, 2) 108 | completion(.success(self.moviesPages[1])) 109 | } 110 | 111 | viewModel.didLoadNextPage() 112 | 113 | // then 114 | let expectedItems = moviesPages 115 | .flatMap { $0.movies } 116 | .map { MoviesListItemViewModel(movie: $0) } 117 | XCTAssertEqual(viewModel.items.value, expectedItems) 118 | XCTAssertEqual(viewModel.currentPage, 2) 119 | XCTAssertFalse(viewModel.hasMorePages) 120 | XCTAssertEqual(searchMoviesUseCaseMock.executeCallCount, 2) 121 | addTeardownBlock { [weak viewModel] in XCTAssertNil(viewModel) } 122 | } 123 | 124 | func test_whenSearchMoviesUseCaseReturnsError_thenViewModelContainsError() { 125 | // given 126 | let searchMoviesUseCaseMock = SearchMoviesUseCaseMock() 127 | 128 | searchMoviesUseCaseMock._execute = { requestValue, _, completion in 129 | XCTAssertEqual(requestValue.page, 1) 130 | completion(.failure(SearchMoviesUseCaseError.someError)) 131 | } 132 | let viewModel = DefaultMoviesListViewModel.make( 133 | searchMoviesUseCase: searchMoviesUseCaseMock 134 | ) 135 | // when 136 | viewModel.didSearch(query: "query") 137 | 138 | // then 139 | XCTAssertNotNil(viewModel.error) 140 | XCTAssertTrue(viewModel.items.value.isEmpty) 141 | XCTAssertEqual(searchMoviesUseCaseMock.executeCallCount, 1) 142 | addTeardownBlock { [weak viewModel] in XCTAssertNil(viewModel) } 143 | } 144 | 145 | func test_whenLastPage_thenHasNoPageIsTrue() { 146 | // given 147 | let searchMoviesUseCaseMock = SearchMoviesUseCaseMock() 148 | searchMoviesUseCaseMock._execute = { requestValue, _, completion in 149 | XCTAssertEqual(requestValue.page, 1) 150 | completion(.success(self.moviesPages[0])) 151 | } 152 | let viewModel = DefaultMoviesListViewModel.make( 153 | searchMoviesUseCase: searchMoviesUseCaseMock 154 | ) 155 | // when 156 | viewModel.didSearch(query: "query") 157 | XCTAssertEqual(searchMoviesUseCaseMock.executeCallCount, 1) 158 | 159 | searchMoviesUseCaseMock._execute = { requestValue, _, completion in 160 | XCTAssertEqual(requestValue.page, 2) 161 | completion(.success(self.moviesPages[1])) 162 | } 163 | 164 | viewModel.didLoadNextPage() 165 | 166 | // then 167 | XCTAssertEqual(viewModel.currentPage, 2) 168 | XCTAssertFalse(viewModel.hasMorePages) 169 | XCTAssertEqual(searchMoviesUseCaseMock.executeCallCount, 2) 170 | addTeardownBlock { [weak viewModel] in XCTAssertNil(viewModel) } 171 | } 172 | 173 | func test_whenSearchMoviesUseCaseReturnsCachedData_thenViewModelShowsFirstCachedDataAndAfterFreshData() { 174 | // given 175 | let cachedPage: MoviesPage = .init( 176 | page: 1, 177 | totalPages: 2, 178 | movies: [.stub(id: "cachedMovieId1")] 179 | ) 180 | let searchMoviesUseCaseMock = SearchMoviesUseCaseMock() 181 | 182 | let viewModel = DefaultMoviesListViewModel( 183 | searchMoviesUseCase: searchMoviesUseCaseMock, 184 | mainQueue: DispatchQueueTypeMock() 185 | ) 186 | 187 | let testItemsBeforeFreshData = { [weak viewModel] in 188 | guard let viewModel else { return } 189 | let expectedItems = cachedPage 190 | .movies 191 | .map { MoviesListItemViewModel(movie: $0) } 192 | 193 | XCTAssertEqual(viewModel.items.value, expectedItems) 194 | } 195 | 196 | searchMoviesUseCaseMock._execute = { requestValue, cached, completion in 197 | XCTAssertEqual(requestValue.page, 1) 198 | cached(cachedPage) 199 | testItemsBeforeFreshData() 200 | completion(.success(self.moviesPages[0])) 201 | } 202 | 203 | // when 204 | viewModel.didSearch(query: "query") 205 | 206 | // then 207 | let expectedItems = moviesPages[0] 208 | .movies 209 | .map { MoviesListItemViewModel(movie: $0) } 210 | XCTAssertEqual(viewModel.items.value, expectedItems) 211 | XCTAssertEqual(viewModel.currentPage, 1) 212 | XCTAssertTrue(viewModel.hasMorePages) 213 | XCTAssertEqual(searchMoviesUseCaseMock.executeCallCount, 1) 214 | addTeardownBlock { [weak viewModel] in XCTAssertNil(viewModel) } 215 | } 216 | 217 | func test_whenSearchMoviesUseCaseReturnsError_thenViewModelShowsCachedData() { 218 | // given 219 | let cachedPage: MoviesPage = .init( 220 | page: 1, 221 | totalPages: 2, 222 | movies: [.stub(id: "cachedMovieId1")] 223 | ) 224 | let searchMoviesUseCaseMock = SearchMoviesUseCaseMock() 225 | 226 | let viewModel = DefaultMoviesListViewModel( 227 | searchMoviesUseCase: searchMoviesUseCaseMock, 228 | mainQueue: DispatchQueueTypeMock() 229 | ) 230 | 231 | searchMoviesUseCaseMock._execute = { requestValue, cached, completion in 232 | XCTAssertEqual(requestValue.page, 1) 233 | cached(cachedPage) 234 | completion(.failure(SearchMoviesUseCaseError.someError)) 235 | } 236 | 237 | // when 238 | viewModel.didSearch(query: "query") 239 | 240 | // then 241 | let expectedItems = cachedPage 242 | .movies 243 | .map { MoviesListItemViewModel(movie: $0) } 244 | XCTAssertEqual(viewModel.items.value, expectedItems) 245 | XCTAssertEqual(viewModel.currentPage, 1) 246 | XCTAssertTrue(viewModel.hasMorePages) 247 | XCTAssertEqual(searchMoviesUseCaseMock.executeCallCount, 1) 248 | addTeardownBlock { [weak viewModel] in XCTAssertNil(viewModel) } 249 | } 250 | 251 | } 252 | 253 | extension DefaultMoviesListViewModel { 254 | static func make( 255 | searchMoviesUseCase: SearchMoviesUseCase 256 | ) -> DefaultMoviesListViewModel { 257 | DefaultMoviesListViewModel( 258 | searchMoviesUseCase: searchMoviesUseCase, 259 | mainQueue: DispatchQueueTypeMock() 260 | ) 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /ExampleMVVMTests/Presentation/MoviesScene/MoviesQueriesListViewModelTests.swift: -------------------------------------------------------------------------------- 1 | @testable import ExampleMVVM 2 | import XCTest 3 | 4 | class MoviesQueriesListViewModelTests: XCTestCase { 5 | 6 | private enum FetchRecentQueriedUseCase: Error { 7 | case someError 8 | } 9 | 10 | var movieQueries = [MovieQuery(query: "query1"), 11 | MovieQuery(query: "query2"), 12 | MovieQuery(query: "query3"), 13 | MovieQuery(query: "query4"), 14 | MovieQuery(query: "query5")] 15 | 16 | class FetchRecentMovieQueriesUseCaseMock: UseCase { 17 | var startCalledCount: Int = 0 18 | var error: Error? 19 | var movieQueries: [MovieQuery] = [] 20 | var completion: (Result<[MovieQuery], Error>) -> Void = { _ in } 21 | 22 | func start() -> Cancellable? { 23 | if let error = error { 24 | completion(.failure(error)) 25 | } else { 26 | completion(.success(movieQueries)) 27 | } 28 | startCalledCount += 1 29 | return nil 30 | } 31 | } 32 | 33 | func makeFetchRecentMovieQueriesUseCase(_ mock: FetchRecentMovieQueriesUseCaseMock) -> FetchRecentMovieQueriesUseCaseFactory { 34 | return { _, completion in 35 | mock.completion = completion 36 | return mock 37 | } 38 | } 39 | 40 | 41 | func test_whenFetchRecentMovieQueriesUseCaseReturnsQueries_thenShowTheseQueries() { 42 | // given 43 | let useCase = FetchRecentMovieQueriesUseCaseMock() 44 | useCase.movieQueries = movieQueries 45 | let viewModel = DefaultMoviesQueryListViewModel.make( 46 | numberOfQueriesToShow: 3, 47 | fetchRecentMovieQueriesUseCaseFactory: makeFetchRecentMovieQueriesUseCase(useCase) 48 | ) 49 | 50 | // when 51 | viewModel.viewWillAppear() 52 | 53 | // then 54 | XCTAssertEqual(viewModel.items.value.map { $0.query }, movieQueries.map { $0.query }) 55 | XCTAssertEqual(useCase.startCalledCount, 1) 56 | } 57 | 58 | func test_whenFetchRecentMovieQueriesUseCaseReturnsError_thenDontShowAnyQuery() { 59 | // given 60 | let useCase = FetchRecentMovieQueriesUseCaseMock() 61 | useCase.error = FetchRecentQueriedUseCase.someError 62 | let viewModel = DefaultMoviesQueryListViewModel.make( 63 | numberOfQueriesToShow: 3, 64 | fetchRecentMovieQueriesUseCaseFactory: makeFetchRecentMovieQueriesUseCase(useCase) 65 | ) 66 | 67 | // when 68 | viewModel.viewWillAppear() 69 | 70 | // then 71 | XCTAssertTrue(viewModel.items.value.isEmpty) 72 | XCTAssertEqual(useCase.startCalledCount, 1) 73 | } 74 | 75 | func test_whenDidSelectQueryEventIsReceived_thenCallDidSelectAction() { 76 | // given 77 | let selectedQueryItem = MovieQuery(query: "query1") 78 | var actionMovieQuery: MovieQuery? 79 | var delegateNotifiedCount = 0 80 | let didSelect: MoviesQueryListViewModelDidSelectAction = { movieQuery in 81 | actionMovieQuery = movieQuery 82 | delegateNotifiedCount += 1 83 | } 84 | 85 | let viewModel = DefaultMoviesQueryListViewModel.make( 86 | numberOfQueriesToShow: 3, 87 | fetchRecentMovieQueriesUseCaseFactory: makeFetchRecentMovieQueriesUseCase(FetchRecentMovieQueriesUseCaseMock()), 88 | didSelect: didSelect 89 | ) 90 | 91 | // when 92 | viewModel.didSelect(item: MoviesQueryListItemViewModel(query: selectedQueryItem.query)) 93 | 94 | // then 95 | XCTAssertEqual(actionMovieQuery, selectedQueryItem) 96 | XCTAssertEqual(delegateNotifiedCount, 1) 97 | } 98 | } 99 | 100 | extension DefaultMoviesQueryListViewModel { 101 | static func make( 102 | numberOfQueriesToShow: Int, 103 | fetchRecentMovieQueriesUseCaseFactory: @escaping FetchRecentMovieQueriesUseCaseFactory, 104 | didSelect: MoviesQueryListViewModelDidSelectAction? = nil 105 | ) -> DefaultMoviesQueryListViewModel { 106 | DefaultMoviesQueryListViewModel( 107 | numberOfQueriesToShow: numberOfQueriesToShow, 108 | fetchRecentMovieQueriesUseCaseFactory: fetchRecentMovieQueriesUseCaseFactory, 109 | didSelect: didSelect, 110 | mainQueue: DispatchQueueTypeMock() 111 | ) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /ExampleMVVMUITests/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 | -------------------------------------------------------------------------------- /ExampleMVVMUITests/Presentation/MoviesScene/MoviesSceneUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | class MoviesSceneUITests: XCTestCase { 4 | 5 | override func setUp() { 6 | 7 | continueAfterFailure = false 8 | XCUIApplication().launch() 9 | } 10 | 11 | // NOTE: for UI tests to work the keyboard of simulator must be on. 12 | // Keyboard shortcut COMMAND + SHIFT + K while simulator has focus 13 | func testOpenMovieDetails_whenSearchBatmanAndTapOnFirstResultRow_thenMovieDetailsViewOpensWithTitleBatman() { 14 | 15 | let app = XCUIApplication() 16 | 17 | // Search for Batman 18 | let searchText = "Batman Begins" 19 | app.searchFields[AccessibilityIdentifier.searchField].tap() 20 | if !app.keys["A"].waitForExistence(timeout: 5) { 21 | XCTFail("The keyboard could not be found. Use keyboard shortcut COMMAND + SHIFT + K while simulator has focus on text input") 22 | } 23 | _ = app.searchFields[AccessibilityIdentifier.searchField].waitForExistence(timeout: 10) 24 | app.searchFields[AccessibilityIdentifier.searchField].typeText(searchText) 25 | app.buttons["search"].tap() 26 | 27 | // Tap on first result row 28 | app.tables.cells.staticTexts[searchText].tap() 29 | 30 | // Make sure movie details view 31 | XCTAssertTrue(app.otherElements[AccessibilityIdentifier.movieDetailsView].waitForExistence(timeout: 5)) 32 | XCTAssertTrue(app.navigationBars[searchText].waitForExistence(timeout: 5)) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'fastlane' 4 | gem 'rake' 5 | gem 'cocoapods' -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.2) 5 | activesupport (4.2.11.1) 6 | i18n (~> 0.7) 7 | minitest (~> 5.1) 8 | thread_safe (~> 0.3, >= 0.3.4) 9 | tzinfo (~> 1.1) 10 | addressable (2.8.0) 11 | public_suffix (>= 2.0.2, < 5.0) 12 | algoliasearch (1.27.1) 13 | httpclient (~> 2.8, >= 2.8.3) 14 | json (>= 1.5.1) 15 | atomos (0.1.3) 16 | babosa (1.0.3) 17 | claide (1.0.3) 18 | cocoapods (1.8.4) 19 | activesupport (>= 4.0.2, < 5) 20 | claide (>= 1.0.2, < 2.0) 21 | cocoapods-core (= 1.8.4) 22 | cocoapods-deintegrate (>= 1.0.3, < 2.0) 23 | cocoapods-downloader (>= 1.2.2, < 2.0) 24 | cocoapods-plugins (>= 1.0.0, < 2.0) 25 | cocoapods-search (>= 1.0.0, < 2.0) 26 | cocoapods-stats (>= 1.0.0, < 2.0) 27 | cocoapods-trunk (>= 1.4.0, < 2.0) 28 | cocoapods-try (>= 1.1.0, < 2.0) 29 | colored2 (~> 3.1) 30 | escape (~> 0.0.4) 31 | fourflusher (>= 2.3.0, < 3.0) 32 | gh_inspector (~> 1.0) 33 | molinillo (~> 0.6.6) 34 | nap (~> 1.0) 35 | ruby-macho (~> 1.4) 36 | xcodeproj (>= 1.11.1, < 2.0) 37 | cocoapods-core (1.8.4) 38 | activesupport (>= 4.0.2, < 6) 39 | algoliasearch (~> 1.0) 40 | concurrent-ruby (~> 1.1) 41 | fuzzy_match (~> 2.0.4) 42 | nap (~> 1.0) 43 | cocoapods-deintegrate (1.0.4) 44 | cocoapods-downloader (1.6.3) 45 | cocoapods-plugins (1.0.0) 46 | nap 47 | cocoapods-search (1.0.0) 48 | cocoapods-stats (1.1.0) 49 | cocoapods-trunk (1.4.1) 50 | nap (>= 0.8, < 2.0) 51 | netrc (~> 0.11) 52 | cocoapods-try (1.1.0) 53 | colored (1.2) 54 | colored2 (3.1.2) 55 | commander-fastlane (4.4.6) 56 | highline (~> 1.7.2) 57 | concurrent-ruby (1.1.5) 58 | declarative (0.0.10) 59 | declarative-option (0.1.0) 60 | digest-crc (0.4.1) 61 | domain_name (0.5.20190701) 62 | unf (>= 0.0.5, < 1.0.0) 63 | dotenv (2.7.5) 64 | emoji_regex (1.0.1) 65 | escape (0.0.4) 66 | excon (0.71.1) 67 | faraday (0.17.3) 68 | multipart-post (>= 1.2, < 3) 69 | faraday-cookie_jar (0.0.6) 70 | faraday (>= 0.7.4) 71 | http-cookie (~> 1.0.0) 72 | faraday_middleware (0.13.1) 73 | faraday (>= 0.7.4, < 1.0) 74 | fastimage (2.1.7) 75 | fastlane (2.139.0) 76 | CFPropertyList (>= 2.3, < 4.0.0) 77 | addressable (>= 2.3, < 3.0.0) 78 | babosa (>= 1.0.2, < 2.0.0) 79 | bundler (>= 1.12.0, < 3.0.0) 80 | colored 81 | commander-fastlane (>= 4.4.6, < 5.0.0) 82 | dotenv (>= 2.1.1, < 3.0.0) 83 | emoji_regex (>= 0.1, < 2.0) 84 | excon (>= 0.71.0, < 1.0.0) 85 | faraday (~> 0.17) 86 | faraday-cookie_jar (~> 0.0.6) 87 | faraday_middleware (~> 0.13.1) 88 | fastimage (>= 2.1.0, < 3.0.0) 89 | gh_inspector (>= 1.1.2, < 2.0.0) 90 | google-api-client (>= 0.29.2, < 0.37.0) 91 | google-cloud-storage (>= 1.15.0, < 2.0.0) 92 | highline (>= 1.7.2, < 2.0.0) 93 | json (< 3.0.0) 94 | jwt (~> 2.1.0) 95 | mini_magick (>= 4.9.4, < 5.0.0) 96 | multi_xml (~> 0.5) 97 | multipart-post (~> 2.0.0) 98 | plist (>= 3.1.0, < 4.0.0) 99 | public_suffix (~> 2.0.0) 100 | rubyzip (>= 1.3.0, < 2.0.0) 101 | security (= 0.1.3) 102 | simctl (~> 1.6.3) 103 | slack-notifier (>= 2.0.0, < 3.0.0) 104 | terminal-notifier (>= 2.0.0, < 3.0.0) 105 | terminal-table (>= 1.4.5, < 2.0.0) 106 | tty-screen (>= 0.6.3, < 1.0.0) 107 | tty-spinner (>= 0.8.0, < 1.0.0) 108 | word_wrap (~> 1.0.0) 109 | xcodeproj (>= 1.13.0, < 2.0.0) 110 | xcpretty (~> 0.3.0) 111 | xcpretty-travis-formatter (>= 0.0.3) 112 | fourflusher (2.3.1) 113 | fuzzy_match (2.0.4) 114 | gh_inspector (1.1.3) 115 | google-api-client (0.36.4) 116 | addressable (~> 2.5, >= 2.5.1) 117 | googleauth (~> 0.9) 118 | httpclient (>= 2.8.1, < 3.0) 119 | mini_mime (~> 1.0) 120 | representable (~> 3.0) 121 | retriable (>= 2.0, < 4.0) 122 | signet (~> 0.12) 123 | google-cloud-core (1.4.1) 124 | google-cloud-env (~> 1.0) 125 | google-cloud-env (1.3.0) 126 | faraday (~> 0.11) 127 | google-cloud-storage (1.25.1) 128 | addressable (~> 2.5) 129 | digest-crc (~> 0.4) 130 | google-api-client (~> 0.33) 131 | google-cloud-core (~> 1.2) 132 | googleauth (~> 0.9) 133 | mini_mime (~> 1.0) 134 | googleauth (0.10.0) 135 | faraday (~> 0.12) 136 | jwt (>= 1.4, < 3.0) 137 | memoist (~> 0.16) 138 | multi_json (~> 1.11) 139 | os (>= 0.9, < 2.0) 140 | signet (~> 0.12) 141 | highline (1.7.10) 142 | http-cookie (1.0.3) 143 | domain_name (~> 0.5) 144 | httpclient (2.8.3) 145 | i18n (0.9.5) 146 | concurrent-ruby (~> 1.0) 147 | json (2.3.0) 148 | jwt (2.1.0) 149 | memoist (0.16.2) 150 | mini_magick (4.10.1) 151 | mini_mime (1.0.2) 152 | minitest (5.13.0) 153 | molinillo (0.6.6) 154 | multi_json (1.14.1) 155 | multi_xml (0.6.0) 156 | multipart-post (2.0.0) 157 | nanaimo (0.2.6) 158 | nap (1.1.0) 159 | naturally (2.2.0) 160 | netrc (0.11.0) 161 | os (1.0.1) 162 | plist (3.5.0) 163 | public_suffix (2.0.5) 164 | rake (13.0.1) 165 | representable (3.0.4) 166 | declarative (< 0.1.0) 167 | declarative-option (< 0.2.0) 168 | uber (< 0.2.0) 169 | retriable (3.1.2) 170 | rouge (2.0.7) 171 | ruby-macho (1.4.0) 172 | rubyzip (1.3.0) 173 | security (0.1.3) 174 | signet (0.12.0) 175 | addressable (~> 2.3) 176 | faraday (~> 0.9) 177 | jwt (>= 1.5, < 3.0) 178 | multi_json (~> 1.10) 179 | simctl (1.6.7) 180 | CFPropertyList 181 | naturally 182 | slack-notifier (2.3.2) 183 | terminal-notifier (2.0.0) 184 | terminal-table (1.8.0) 185 | unicode-display_width (~> 1.1, >= 1.1.1) 186 | thread_safe (0.3.6) 187 | tty-cursor (0.7.0) 188 | tty-screen (0.7.0) 189 | tty-spinner (0.9.2) 190 | tty-cursor (~> 0.7) 191 | tzinfo (1.2.10) 192 | thread_safe (~> 0.1) 193 | uber (0.1.0) 194 | unf (0.1.4) 195 | unf_ext 196 | unf_ext (0.0.7.6) 197 | unicode-display_width (1.6.0) 198 | word_wrap (1.0.0) 199 | xcodeproj (1.14.0) 200 | CFPropertyList (>= 2.3.3, < 4.0) 201 | atomos (~> 0.1.3) 202 | claide (>= 1.0.2, < 2.0) 203 | colored2 (~> 3.1) 204 | nanaimo (~> 0.2.6) 205 | xcpretty (0.3.0) 206 | rouge (~> 2.0.7) 207 | xcpretty-travis-formatter (1.0.0) 208 | xcpretty (~> 0.2, >= 0.0.7) 209 | 210 | PLATFORMS 211 | ruby 212 | 213 | DEPENDENCIES 214 | cocoapods 215 | fastlane 216 | rake 217 | 218 | BUNDLED WITH 219 | 2.1.4 220 | -------------------------------------------------------------------------------- /MVVM Local Swift Packages.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Clean-Architecture-MVVM/7880b4ebd8f81a25c97f646ed1fc7678ff1a7de4/MVVM Local Swift Packages.zip -------------------------------------------------------------------------------- /MVVM Modular Layers Pods.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Clean-Architecture-MVVM/7880b4ebd8f81a25c97f646ed1fc7678ff1a7de4/MVVM Modular Layers Pods.zip -------------------------------------------------------------------------------- /MVVM Templates/MVVM/MVVM.xctemplate/TemplateInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DefaultCompletionName 6 | Scene 7 | Description 8 | This generates a new scene using MVVM architecture. It consists of the view controller, view model, router and storyboard. 9 | Kind 10 | Xcode.IDEKit.TextSubstitutionFileTemplateKind 11 | Options 12 | 13 | 14 | Description 15 | The name of the scene to create 16 | Identifier 17 | sceneName 18 | Name 19 | New Scene Name: 20 | NotPersisted 21 | 22 | Required 23 | 24 | Type 25 | text 26 | 27 | 28 | Default 29 | ___VARIABLE_sceneName:identifier___ 30 | Identifier 31 | sceneIdentifier 32 | Type 33 | static 34 | 35 | 36 | Default 37 | ___VARIABLE_sceneIdentifier___ViewController 38 | Description 39 | The view controller name 40 | Identifier 41 | viewControllerName 42 | Name 43 | View Controller Name: 44 | Required 45 | 46 | Type 47 | static 48 | 49 | 50 | Default 51 | ___VARIABLE_sceneIdentifier___ViewModel 52 | Description 53 | The view model name 54 | Identifier 55 | viewModelName 56 | Name 57 | ViewModel Name: 58 | Required 59 | 60 | Type 61 | static 62 | 63 | 64 | Default 65 | ___VARIABLE_sceneIdentifier___ViewController 66 | Description 67 | The storyboard name 68 | Identifier 69 | storyboardName 70 | Name 71 | Storyboard Name: 72 | Required 73 | 74 | Type 75 | static 76 | 77 | 78 | Platforms 79 | 80 | com.apple.platform.iphoneos 81 | 82 | SortOrder 83 | 9 84 | Summary 85 | This generates a new scene using MVVM architecture. 86 | 87 | 88 | -------------------------------------------------------------------------------- /MVVM Templates/MVVM/MVVM.xctemplate/___VARIABLE_sceneIdentifier___ViewController.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 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /MVVM Templates/MVVM/MVVM.xctemplate/___VARIABLE_sceneIdentifier___ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ___FILENAME___ 3 | // ___PROJECTNAME___ 4 | // 5 | // Created by ___FULLUSERNAME___ on ___DATE___. 6 | // Copyright (c) ___YEAR___ All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ___VARIABLE_sceneIdentifier___ViewController: UIViewController, StoryboardInstantiable { 12 | 13 | var viewModel: ___VARIABLE_sceneIdentifier___ViewModel! 14 | 15 | class func create(with viewModel: ___VARIABLE_sceneIdentifier___ViewModel) -> ___VARIABLE_sceneIdentifier___ViewController { 16 | let vc = ___VARIABLE_sceneIdentifier___ViewController.instantiateViewController() 17 | vc.viewModel = viewModel 18 | return vc 19 | } 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | bind(to: viewModel) 25 | viewModel.viewDidLoad() 26 | } 27 | 28 | func bind(to viewModel: ___VARIABLE_sceneIdentifier___ViewModel) { 29 | 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /MVVM Templates/MVVM/MVVM.xctemplate/___VARIABLE_sceneIdentifier___ViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ___FILENAME___ 3 | // ___PROJECTNAME___ 4 | // 5 | // Created by ___FULLUSERNAME___ on ___DATE___. 6 | // Copyright (c) ___YEAR___ All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol ___VARIABLE_sceneIdentifier___ViewModelInput { 12 | func viewDidLoad() 13 | } 14 | 15 | protocol ___VARIABLE_sceneIdentifier___ViewModelOutput { 16 | 17 | } 18 | 19 | protocol ___VARIABLE_sceneIdentifier___ViewModel: ___VARIABLE_sceneIdentifier___ViewModelInput, ___VARIABLE_sceneIdentifier___ViewModelOutput { } 20 | 21 | class Default___VARIABLE_sceneIdentifier___ViewModel: ___VARIABLE_sceneIdentifier___ViewModel { 22 | 23 | // MARK: - OUTPUT 24 | 25 | } 26 | 27 | // MARK: - INPUT. View event methods 28 | extension Default___VARIABLE_sceneIdentifier___ViewModel { 29 | func viewDidLoad() { 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /MVVM Templates/MVVMR/MVVMR.xctemplate/TemplateInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DefaultCompletionName 6 | Scene 7 | Description 8 | This generates a new scene using MVVMR architecture. It consists of the view controller, view model, route handling and storyboard. 9 | Kind 10 | Xcode.IDEKit.TextSubstitutionFileTemplateKind 11 | Options 12 | 13 | 14 | Description 15 | The name of the scene to create 16 | Identifier 17 | sceneName 18 | Name 19 | New Scene Name: 20 | NotPersisted 21 | 22 | Required 23 | 24 | Type 25 | text 26 | 27 | 28 | Default 29 | ___VARIABLE_sceneName:identifier___ 30 | Identifier 31 | sceneIdentifier 32 | Type 33 | static 34 | 35 | 36 | Default 37 | ___VARIABLE_sceneIdentifier___ViewController 38 | Description 39 | The view controller name 40 | Identifier 41 | viewControllerName 42 | Name 43 | View Controller Name: 44 | Required 45 | 46 | Type 47 | static 48 | 49 | 50 | Default 51 | ___VARIABLE_sceneIdentifier___ViewModel 52 | Description 53 | The view model name 54 | Identifier 55 | viewModelName 56 | Name 57 | ViewModel Name: 58 | Required 59 | 60 | Type 61 | static 62 | 63 | 64 | Default 65 | ___VARIABLE_sceneIdentifier___ViewController 66 | Description 67 | The storyboard name 68 | Identifier 69 | storyboardName 70 | Name 71 | Storyboard Name: 72 | Required 73 | 74 | Type 75 | static 76 | 77 | 78 | Platforms 79 | 80 | com.apple.platform.iphoneos 81 | 82 | SortOrder 83 | 9 84 | Summary 85 | This generates a new scene using MVVMR architecture. 86 | 87 | 88 | -------------------------------------------------------------------------------- /MVVM Templates/MVVMR/MVVMR.xctemplate/___VARIABLE_sceneIdentifier___ViewController.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 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /MVVM Templates/MVVMR/MVVMR.xctemplate/___VARIABLE_sceneIdentifier___ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ___FILENAME___ 3 | // ___PROJECTNAME___ 4 | // 5 | // Created by ___FULLUSERNAME___ on ___DATE___. 6 | // Copyright (c) ___YEAR___ All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ___VARIABLE_sceneIdentifier___ViewController: UIViewController, StoryboardInstantiable { 12 | 13 | var viewModel: ___VARIABLE_sceneIdentifier___ViewModel! 14 | 15 | class func create(with viewModel: ___VARIABLE_sceneIdentifier___ViewModel) -> ___VARIABLE_sceneIdentifier___ViewController { 16 | let vc = ___VARIABLE_sceneIdentifier___ViewController.instantiateViewController() 17 | vc.viewModel = viewModel 18 | return vc 19 | } 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | bind(to: viewModel) 25 | viewModel.viewDidLoad() 26 | } 27 | 28 | func bind(to viewModel: ___VARIABLE_sceneIdentifier___ViewModel) { 29 | viewModel.route.observe(on: self) { [weak self] route in 30 | self?.handle(route) 31 | } 32 | } 33 | } 34 | 35 | // MARK: - Perform Routing 36 | 37 | extension ___VARIABLE_sceneIdentifier___ViewController { 38 | func handle(_ route: ___VARIABLE_sceneIdentifier___ViewModelRoute?) { 39 | guard let route = route else { return } 40 | switch route { 41 | case .showDetails(let itemId): 42 | // present view 43 | break 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /MVVM Templates/MVVMR/MVVMR.xctemplate/___VARIABLE_sceneIdentifier___ViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ___FILENAME___ 3 | // ___PROJECTNAME___ 4 | // 5 | // Created by ___FULLUSERNAME___ on ___DATE___. 6 | // Copyright (c) ___YEAR___ All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum ___VARIABLE_sceneIdentifier___ViewModelRoute { 12 | case showDetails(itemId: String) 13 | } 14 | 15 | protocol ___VARIABLE_sceneIdentifier___ViewModelInput { 16 | func viewDidLoad() 17 | } 18 | 19 | protocol ___VARIABLE_sceneIdentifier___ViewModelOutput { 20 | var route: Observable<___VARIABLE_sceneIdentifier___ViewModelRoute?> { get } 21 | } 22 | 23 | protocol ___VARIABLE_sceneIdentifier___ViewModel: ___VARIABLE_sceneIdentifier___ViewModelInput, ___VARIABLE_sceneIdentifier___ViewModelOutput { } 24 | 25 | class Default___VARIABLE_sceneIdentifier___ViewModel: ___VARIABLE_sceneIdentifier___ViewModel { 26 | 27 | // MARK: - OUTPUT 28 | private(set) var route: Observable<___VARIABLE_sceneIdentifier___ViewModelRoute?> = Observable(nil) 29 | } 30 | 31 | // MARK: - INPUT. View event methods 32 | extension Default___VARIABLE_sceneIdentifier___ViewModel { 33 | func viewDidLoad() { } 34 | } 35 | -------------------------------------------------------------------------------- /MVVM Templates/README.md: -------------------------------------------------------------------------------- 1 | Templates iOS application 2 | === 3 | 4 | To use a template: copy folder with template (for example MVVM folder without .extension) 5 | and paste it inside path ~/Library/Developer/Xcode/Templates/ 6 | You can locate this folder by using Finder (Go to folder...) Shift+Command+G 7 | Then when creating, New File... , Scroll down to section with Tempalte name that you added. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Template iOS App using Clean Architecture and MVVM 3 | 4 | iOS Project implemented with Clean Layered Architecture and MVVM. (Can be used as Template project by replacing item name “Movie”). **More information in medium post**: Medium Post about Clean Architecture + MVVM 5 | 6 | 7 | ![Alt text](README_FILES/CleanArchitecture+MVVM.png?raw=true "Clean Architecture Layers") 8 | 9 | ## Layers 10 | * **Domain Layer** = Entities + Use Cases + Repositories Interfaces 11 | * **Data Repositories Layer** = Repositories Implementations + API (Network) + Persistence DB 12 | * **Presentation Layer (MVVM)** = ViewModels + Views 13 | 14 | ### Dependency Direction 15 | ![Alt text](README_FILES/CleanArchitectureDependencies.png?raw=true "Modules Dependencies") 16 | 17 | **Note:** **Domain Layer** should not include anything from other layers(e.g Presentation — UIKit or SwiftUI or Data Layer — Mapping Codable) 18 | 19 | ## Architecture concepts used here 20 | * Clean Architecture https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html 21 | * Advanced iOS App Architecture https://www.raywenderlich.com/8477-introducing-advanced-ios-app-architecture 22 | * [MVVM](ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList) 23 | * Data Binding using [Observable](ExampleMVVM/Presentation/Utils/Observable.swift) without 3rd party libraries 24 | * [Dependency Injection](ExampleMVVM/Application/DIContainer/AppDIContainer.swift) 25 | * [Flow Coordinator](ExampleMVVM/Presentation/MoviesScene/Flows/MoviesSearchFlowCoordinator.swift) 26 | * [Data Transfer Object (DTO)](https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/blob/master/ExampleMVVM/Data/Network/DataMapping/MoviesResponseDTO%2BMapping.swift) 27 | * [Response Data Caching](https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/blob/master/ExampleMVVM/Data/Repositories/DefaultMoviesRepository.swift) 28 | * [ViewController Lifecycle Behavior](https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/blob/3c47e8a4b9ae5dfce36f746242d1f40b6829079d/ExampleMVVM/Presentation/Utils/Extensions/UIViewController%2BAddBehaviors.swift#L7) 29 | * [SwiftUI and UIKit view](ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/View/SwiftUI/MoviesQueryListView.swift) implementations by reusing same [ViewModel](ExampleMVVM/Presentation/MoviesScene/MoviesQueriesList/ViewModel/MoviesQueryListViewModel.swift) (at least Xcode 11 required) 30 | * Error handling examples: in [ViewModel](https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/blob/201de7759e2d5634e3bb4b5ad524c4242c62b306/ExampleMVVM/Presentation/MoviesScene/MoviesList/ViewModel/MoviesListViewModel.swift#L116), in [Networking](https://github.com/kudoleh/iOS-Clean-Architecture-MVVM/blob/201de7759e2d5634e3bb4b5ad524c4242c62b306/ExampleMVVM/Infrastructure/Network/NetworkService.swift#L84) 31 | * CI Pipeline ([Travis CI + Fastlane](.travis.yml)) 32 | 33 | ## Includes 34 | * Pagination 35 | * Unit Tests for Use Cases(Domain Layer), ViewModels(Presentation Layer), NetworkService(Infrastructure Layer) 36 | * Dark Mode 37 | * Size Classes and UIStackView in Detail view 38 | * SwiftUI example, demostration that presentation layer does not change, only UI (at least Xcode 11 required) 39 | 40 | ## Networking 41 | If you would like to reuse Networking from this example project as repository I made it availabe [here](https://github.com/kudoleh/SENetworking) 42 | 43 | ## Views in Code vs Storyboard 44 | This repository uses Storyboards (except one view written in SwiftUI). There is another similar repository but instead of using Storyboards, all Views are written in Code. 45 | It also uses UITableViewDiffableDataSource: 46 | [iOS-Clean-Architecture-MVVM-Views-In-Code](https://github.com/kudoleh/iOS-Clean-Architecture-MVVM-Views-In-Code) 47 | 48 | ## How to use app 49 | To search a movie, write a name of a movie inside searchbar and hit search button. There are two network calls: request movies and request poster images. Every successful search query is stored persistently. 50 | 51 | 52 | https://user-images.githubusercontent.com/6785311/236615779-153ef846-ae0b-4ce8-908a-57fca7158b9d.mp4 53 | 54 | 55 | ## Requirements 56 | * Xcode Version 11.2.1+ Swift 5.0+ 57 | 58 | -------------------------------------------------------------------------------- /README_FILES/CleanArchitecture+MVVM.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Clean-Architecture-MVVM/7880b4ebd8f81a25c97f646ed1fc7678ff1a7de4/README_FILES/CleanArchitecture+MVVM.png -------------------------------------------------------------------------------- /README_FILES/CleanArchitectureDependencies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kudoleh/iOS-Clean-Architecture-MVVM/7880b4ebd8f81a25c97f646ed1fc7678ff1a7de4/README_FILES/CleanArchitectureDependencies.png --------------------------------------------------------------------------------