├── .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 | 
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 | 
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
--------------------------------------------------------------------------------