├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── MVVMC.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── MVVMC.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── MVVMC ├── App │ ├── AppCoordinator.swift │ ├── Base │ │ ├── BaseCoordinator.swift │ │ ├── BaseNavigationController.swift │ │ └── ViewControllerWithSideMenu.swift │ ├── Dashboard │ │ ├── Dashboard.storyboard │ │ ├── DashboardCoordinator.swift │ │ ├── DashboardViewController.swift │ │ └── DashboardViewModel.swift │ ├── DrawerMenu │ │ ├── Drawer.storyboard │ │ ├── DrawerMenuCoordinator.swift │ │ ├── DrawerMenuViewController.swift │ │ └── DrawerMenuViewModel.swift │ ├── LaunchScreen.storyboard │ ├── OnBoarding │ │ ├── OnBoarding.storyboard │ │ ├── OnBoardingCoordinator.swift │ │ ├── SetNameViewController.swift │ │ ├── SetNameViewModel.swift │ │ ├── SetOptionsViewController.swift │ │ └── SetOptionsViewModel.swift │ ├── Settings │ │ ├── Settings.storyboard │ │ ├── SettingsCoordinator.swift │ │ ├── SettingsViewController.swift │ │ └── SettingsViewModel.swift │ └── SignIn │ │ ├── SignIn.storyboard │ │ ├── SignInCoordinator.swift │ │ ├── SignInViewController.swift │ │ └── SignInViewModel.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ ├── avatar.imageset │ │ ├── Contents.json │ │ ├── avatar-1.png │ │ ├── avatar-2.png │ │ └── avatar.png │ ├── menu.imageset │ │ ├── Contents.json │ │ ├── baseline_menu_black_24pt_1x.png │ │ ├── baseline_menu_black_24pt_2x.png │ │ └── baseline_menu_black_24pt_3x.png │ ├── swift_logo.imageset │ │ ├── Contents.json │ │ ├── swift_logo-1.png │ │ ├── swift_logo-2.png │ │ └── swift_logo.png │ └── tools.imageset │ │ ├── Contents.json │ │ └── tools.png ├── Constants.swift ├── Controls │ ├── ButtonWithProgress.swift │ ├── FancyButton.swift │ ├── LocalizedButton.swift │ ├── LocalizedLabel.swift │ └── LocalizedTextField.swift ├── DependencyInjection │ ├── Container+Coordinators.swift │ ├── Container+RegisterDependencies.swift │ ├── Container+Services.swift │ └── Container+ViewModels.swift ├── Extensions │ ├── ApiResponse+Print.swift │ ├── Data+Json.swift │ ├── Encodable+Json.swift │ ├── String+Localization.swift │ ├── String+Trim.swift │ └── UIViewControllerExtensions.swift ├── Info.plist ├── Models │ ├── AlertMessage.swift │ ├── AppStoryboard.swift │ ├── Credentials.swift │ ├── DrawerMenuScreen.swift │ ├── OnBoardingData.swift │ ├── Session.swift │ ├── SettingKey.swift │ └── Token.swift ├── Networking │ ├── ApiEndpoints │ │ ├── GeneralEndpoints.swift │ │ ├── SessionEndpoints.swift │ │ └── TasksEndpoints.swift │ ├── BackendRestClient.swift │ ├── DTO │ │ ├── Request │ │ │ └── SignInRequest.swift │ │ └── Response │ │ │ ├── ErrorResponse.swift │ │ │ ├── MeResponse.swift │ │ │ ├── SignInResponse.swift │ │ │ └── TranslationsResponse.swift │ └── Models │ │ ├── ApiError.swift │ │ ├── ApiRequest.swift │ │ ├── ApiResponse.swift │ │ ├── HttpMethod.swift │ │ └── HttpStatusCode.swift ├── Protocols │ ├── DataManager.swift │ ├── HttpClient.swift │ └── Storyboarded.swift ├── Services │ ├── AlertDispatcher.swift │ ├── SessionService.swift │ ├── TranslationsService.swift │ └── UserDataManager.swift ├── Utils │ ├── FileUtils.swift │ ├── Json.swift │ ├── LocalizationUtils.swift │ ├── Logger.swift │ └── ViewControllerUtils.swift └── translations.json ├── MVVMCTests ├── Info.plist └── MVVMCTests.swift ├── MVVMCUITests ├── Info.plist └── MVVMCUITests.swift ├── Mocks └── HttpClientMock.swift ├── Podfile ├── Podfile.lock ├── README.md ├── coordinators.png └── screenshots.png /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: wojciech-kulik 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | # Package.resolved 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | # Pods/ 50 | 51 | # Carthage 52 | # 53 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 54 | Carthage/Checkouts 55 | 56 | # Carthage/ 57 | # Carthage/Build 58 | 59 | # fastlane 60 | # 61 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 62 | # screenshots whenever they are needed. 63 | # For more information about the recommended setup visit: 64 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 65 | 66 | fastlane/report.xml 67 | fastlane/Preview.html 68 | fastlane/screenshots/**/*.png 69 | fastlane/test_output 70 | Carthage 71 | Pods 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Wojciech Kulik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MVVMC.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /MVVMC.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MVVMC.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /MVVMC.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /MVVMC/App/AppCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | import SideMenu 4 | 5 | class AppCoordinator: BaseCoordinator { 6 | private let disposeBag = DisposeBag() 7 | private let sessionService: SessionService 8 | private var window = UIWindow(frame: UIScreen.main.bounds) 9 | 10 | private var drawerMenu: SideMenuNavigationController? { 11 | return SideMenuManager.default.leftMenuNavigationController 12 | } 13 | 14 | init(sessionService: SessionService) { 15 | self.sessionService = sessionService 16 | } 17 | 18 | override func start() { 19 | window.makeKeyAndVisible() 20 | 21 | sessionService.sessionState == nil 22 | ? showSignIn() 23 | : showDashboard() 24 | 25 | subscribeToSessionChanges() 26 | } 27 | 28 | private func subscribeToSessionChanges() { 29 | sessionService.didSignIn 30 | .subscribe(onNext: { [weak self] in self?.showDashboard() }) 31 | .disposed(by: disposeBag) 32 | 33 | sessionService.didSignOut 34 | .subscribe(onNext: { [weak self] in 35 | guard let self = self else { return } 36 | 37 | if self.drawerMenu?.isHidden ?? true { 38 | self.showSignIn() 39 | } else { 40 | self.drawerMenu?.dismiss(animated: true, completion: self.showSignIn) 41 | } 42 | }) 43 | .disposed(by: disposeBag) 44 | } 45 | 46 | private func showSignIn() { 47 | removeChildCoordinators() 48 | 49 | let coordinator = AppDelegate.container.resolve(SignInCoordinator.self)! 50 | start(coordinator: coordinator) 51 | 52 | ViewControllerUtils.setRootViewController( 53 | window: window, 54 | viewController: coordinator.navigationController, 55 | withAnimation: true) 56 | } 57 | 58 | private func showDashboard() { 59 | removeChildCoordinators() 60 | 61 | let coordinator = AppDelegate.container.resolve(DrawerMenuCoordinator.self)! 62 | coordinator.navigationController = BaseNavigationController() 63 | start(coordinator: coordinator) 64 | 65 | ViewControllerUtils.setRootViewController( 66 | window: window, 67 | viewController: coordinator.navigationController, 68 | withAnimation: true) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /MVVMC/App/Base/BaseCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | protocol Coordinator: AnyObject { 5 | var navigationController: UINavigationController { get set } 6 | var parentCoordinator: Coordinator? { get set } 7 | 8 | func start() 9 | func start(coordinator: Coordinator) 10 | func didFinish(coordinator: Coordinator) 11 | func removeChildCoordinators() 12 | } 13 | 14 | class BaseCoordinator: Coordinator { 15 | var navigationController = UINavigationController() 16 | var childCoordinators = [Coordinator]() 17 | var parentCoordinator: Coordinator? 18 | 19 | func start() { 20 | fatalError("Start method should be implemented.") 21 | } 22 | 23 | func start(coordinator: Coordinator) { 24 | childCoordinators += [coordinator] 25 | coordinator.parentCoordinator = self 26 | coordinator.start() 27 | } 28 | 29 | func removeChildCoordinators() { 30 | childCoordinators.forEach { $0.removeChildCoordinators() } 31 | childCoordinators.removeAll() 32 | } 33 | 34 | func didFinish(coordinator: Coordinator) { 35 | if let index = childCoordinators.firstIndex(where: { $0 === coordinator }) { 36 | childCoordinators.remove(at: index) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /MVVMC/App/Base/BaseNavigationController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class BaseNavigationController: UINavigationController { 5 | override var preferredStatusBarStyle: UIStatusBarStyle { 6 | return .lightContent 7 | } 8 | 9 | override func viewDidLoad() { 10 | super.viewDidLoad() 11 | navigationBar.isTranslucent = false 12 | navigationBar.tintColor = .white 13 | navigationBar.barTintColor = Constants.mainColor 14 | navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /MVVMC/App/Base/ViewControllerWithSideMenu.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import SideMenu 4 | 5 | class ViewControllerWithSideMenu: UIViewController { 6 | var panGesture = UIPanGestureRecognizer() 7 | var edgeGesture = UIScreenEdgePanGestureRecognizer() 8 | 9 | override func viewDidLoad() { 10 | super.viewDidLoad() 11 | panGesture = SideMenuManager.default.addPanGestureToPresent(toView: navigationController!.navigationBar) 12 | edgeGesture = SideMenuManager.default.addScreenEdgePanGesturesToPresent(toView: navigationController!.view, forMenu: .left) 13 | 14 | navigationItem.leftBarButtonItem = UIBarButtonItem(image: #imageLiteral(resourceName: "menu"), style: .plain, target: self, action: #selector(hamburgerMenuClicked)) 15 | navigationItem.leftBarButtonItem?.accessibilityIdentifier = "menuButton" 16 | } 17 | 18 | override func viewWillAppear(_ animated: Bool) { 19 | super.viewWillAppear(animated) 20 | enableSideMenu() 21 | } 22 | 23 | override func viewWillDisappear(_ animated: Bool) { 24 | super.viewWillDisappear(true) 25 | disableSideMenu() 26 | } 27 | 28 | func disableSideMenu() { 29 | panGesture.isEnabled = false 30 | edgeGesture.isEnabled = false 31 | } 32 | 33 | func enableSideMenu() { 34 | panGesture.isEnabled = true 35 | edgeGesture.isEnabled = true 36 | } 37 | 38 | func showSideMenu() { 39 | present(SideMenuManager.default.leftMenuNavigationController!, animated: true, completion: nil) 40 | } 41 | 42 | @objc func hamburgerMenuClicked() { 43 | showSideMenu() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /MVVMC/App/Dashboard/Dashboard.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 55 | 56 | 57 | 58 | 59 | 60 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /MVVMC/App/Dashboard/DashboardCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class DashboardCoordinator: BaseCoordinator { 5 | private let dashboardViewModel: DashboardViewModel 6 | private let dataManager: DataManager 7 | 8 | init(dashboardViewModel: DashboardViewModel, dataManager: DataManager) { 9 | self.dashboardViewModel = dashboardViewModel 10 | self.dataManager = dataManager 11 | } 12 | 13 | override func start() { 14 | let viewController = DashboardViewController.instantiate() 15 | viewController.viewModel = dashboardViewModel 16 | 17 | navigationController.viewControllers = [viewController] 18 | 19 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 20 | self.showOnBoardingIfNeeded() 21 | } 22 | } 23 | 24 | func showOnBoardingIfNeeded() { 25 | guard dataManager.get(key: SettingKey.onBoardingData, type: OnBoardingData.self) == nil else { return } 26 | 27 | let coordinator = AppDelegate.container.resolve(OnBoardingCoordinator.self)! 28 | coordinator.navigationController = navigationController 29 | 30 | start(coordinator: coordinator) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /MVVMC/App/Dashboard/DashboardViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import RxSwift 4 | 5 | class DashboardViewController: ViewControllerWithSideMenu, Storyboarded { 6 | static var storyboard = AppStoryboard.dashboard 7 | private let disposeBag = DisposeBag() 8 | 9 | @IBOutlet weak var activityIndicator: UIActivityIndicatorView! 10 | @IBOutlet weak var tableView: UITableView! 11 | @IBOutlet weak var welcomeLabel: UILabel! 12 | var viewModel: DashboardViewModel? 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | setUpView() 17 | } 18 | 19 | private func setUpView() { 20 | guard let viewModel = viewModel else { return } 21 | 22 | title = viewModel.title 23 | tableView.tableFooterView = UIView() 24 | 25 | viewModel.isLoading 26 | .bind(to: activityIndicator.rx.isAnimating) 27 | .disposed(by: disposeBag) 28 | 29 | viewModel.tasks 30 | .bind(to: tableView.rx.items(cellIdentifier: "defaultCell")) { row, model, cell in 31 | cell.textLabel?.text = model 32 | cell.selectionStyle = .none 33 | } 34 | .disposed(by: disposeBag) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /MVVMC/App/Dashboard/DashboardViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | class DashboardViewModel { 5 | private let sessionService: SessionService 6 | private let disposeBag = DisposeBag() 7 | private let restClient: BackendRestClient 8 | 9 | let title = "Dashboard".localized 10 | let isLoading = BehaviorSubject(value: true) 11 | var tasks = BehaviorSubject(value: [String]()) 12 | 13 | init(sessionService: SessionService, restClient: BackendRestClient) { 14 | self.sessionService = sessionService 15 | self.restClient = restClient 16 | 17 | sessionService.refreshProfile() 18 | .subscribe() 19 | .disposed(by: disposeBag) 20 | 21 | fetchTasks() 22 | } 23 | 24 | private func fetchTasks() { 25 | isLoading.onNext(true) 26 | restClient.request(TasksEndpoints.FetchTasks()) 27 | .asDriver(onErrorJustReturn: [String]()) 28 | .do(onNext: { [weak self] _ in self?.isLoading.onNext(false) }) 29 | .drive(tasks) 30 | .disposed(by: disposeBag) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /MVVMC/App/DrawerMenu/Drawer.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 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /MVVMC/App/DrawerMenu/DrawerMenuCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | import RxCocoa 4 | import SideMenu 5 | 6 | class DrawerMenuCoordinator: BaseCoordinator { 7 | private let disposeBag = DisposeBag() 8 | private let sessionService: SessionService 9 | private let drawerMenuViewModel: DrawerMenuViewModel 10 | 11 | init(sessionService: SessionService, drawerMenuViewModel: DrawerMenuViewModel) { 12 | self.drawerMenuViewModel = drawerMenuViewModel 13 | self.sessionService = sessionService 14 | } 15 | 16 | override func start() { 17 | drawerMenuViewModel.didSelectScreen 18 | .distinctUntilChanged() 19 | .subscribe(onNext: { [weak self] screen in self?.selectScreen(screen) }) 20 | .disposed(by: disposeBag) 21 | 22 | let drawerMenu = SideMenuManager.default.leftMenuNavigationController 23 | let menuViewController = drawerMenu?.topViewController as? DrawerMenuViewController 24 | menuViewController?.viewModel = drawerMenuViewModel 25 | } 26 | 27 | func selectScreen(_ screen: DrawerMenuScreen) { 28 | Logger.info("Selected screen: \(screen)") 29 | 30 | switch screen { 31 | case .dashboard: 32 | removeChildCoordinators() 33 | let coordinator = AppDelegate.container.resolve(DashboardCoordinator.self)! 34 | coordinator.navigationController = navigationController 35 | start(coordinator: coordinator) 36 | 37 | case .settings: 38 | removeChildCoordinators() 39 | let coordinator = AppDelegate.container.resolve(SettingsCoordinator.self)! 40 | coordinator.navigationController = navigationController 41 | start(coordinator: coordinator) 42 | 43 | case .signOut: 44 | sessionService.signOut() 45 | .subscribe() 46 | .disposed(by: disposeBag) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /MVVMC/App/DrawerMenu/DrawerMenuViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import RxSwift 4 | import RxCocoa 5 | 6 | class DrawerMenuViewController: UIViewController, Storyboarded { 7 | static var storyboard = AppStoryboard.drawer 8 | 9 | @IBOutlet weak var tableView: UITableView! 10 | @IBOutlet weak var nameLabel: UILabel! 11 | @IBOutlet weak var emailLabel: UILabel! 12 | 13 | private var selectedRow: Int = 0 14 | private let disposeBag = DisposeBag() 15 | var viewModel: DrawerMenuViewModel? 16 | 17 | override func viewDidLoad() { 18 | super.viewDidLoad() 19 | tableView.tableFooterView = UIView() 20 | setUpBindings() 21 | } 22 | 23 | override func viewWillAppear(_ animated: Bool) { 24 | nameLabel.text = viewModel?.fullName 25 | emailLabel.text = viewModel?.emailAddress 26 | } 27 | 28 | private func setUpBindings() { 29 | guard let viewModel = viewModel, tableView != nil else { return } 30 | 31 | selectedRow = 0 32 | 33 | viewModel.menuItems 34 | .bind(to: tableView.rx.items(cellIdentifier: "defaultCell")) { [weak self] row, model, cell in 35 | cell.selectionStyle = .none 36 | cell.textLabel?.text = model.localizedUpper 37 | cell.textLabel?.textColor = self?.selectedRow == row ? .white : .darkGray 38 | cell.backgroundColor = self?.selectedRow == row 39 | ? Constants.mainColor 40 | : UIColor.clear 41 | } 42 | .disposed(by: disposeBag) 43 | 44 | tableView.rx.itemSelected 45 | .subscribe(onNext: { [weak self] indexPath in 46 | guard let self = self else { return } 47 | self.selectedRow = indexPath.row 48 | self.tableView.reloadData() 49 | 50 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 51 | self.dismiss(animated: true) { 52 | let selectedScreen = DrawerMenuScreen(rawValue: indexPath.row) ?? .dashboard 53 | self.viewModel?.didSelectScreen.onNext(selectedScreen) 54 | } 55 | } 56 | }) 57 | .disposed(by: disposeBag) 58 | 59 | tableView.reloadData() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /MVVMC/App/DrawerMenu/DrawerMenuViewModel.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | 3 | class DrawerMenuViewModel { 4 | private let disposeBag = DisposeBag() 5 | private let sessionService: SessionService 6 | private let dataManager: DataManager 7 | 8 | let didSelectScreen = BehaviorSubject(value: DrawerMenuScreen.dashboard) 9 | 10 | var fullName: String { 11 | guard let onBoarding = dataManager.get(key: SettingKey.onBoardingData, type: OnBoardingData.self) else { return "n/a" } 12 | return "\(onBoarding.firstName) \(onBoarding.lastName)" 13 | } 14 | 15 | var emailAddress: String { 16 | return sessionService.sessionState?.email ?? "n/a" 17 | } 18 | 19 | let menuItems = Observable.just([ 20 | "Dashboard", 21 | "Settings", 22 | "SignOut" 23 | ]) 24 | 25 | init(sessionService: SessionService, dataManager: DataManager) { 26 | self.sessionService = sessionService 27 | self.dataManager = dataManager 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /MVVMC/App/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 | 27 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /MVVMC/App/OnBoarding/OnBoarding.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 107 | 120 | 121 | 122 | 123 | 124 | 133 | 134 | 135 | 136 | 137 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /MVVMC/App/OnBoarding/OnBoardingCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | import UIKit 4 | 5 | class OnBoardingCoordinator: BaseCoordinator { 6 | var onBoardingViewController: BaseNavigationController! 7 | 8 | private let disposeBag = DisposeBag() 9 | private let dataManager: DataManager 10 | private let setNameViewModel: SetNameViewModel 11 | private let setOptionsViewModel: SetOptionsViewModel 12 | 13 | init(setNameViewModel: SetNameViewModel, setOptionsViewModel: SetOptionsViewModel, 14 | dataManager: DataManager) { 15 | self.setNameViewModel = setNameViewModel 16 | self.setOptionsViewModel = setOptionsViewModel 17 | self.dataManager = dataManager 18 | } 19 | 20 | override func start() { 21 | setUpBindings() 22 | 23 | let viewController = SetNameViewController.instantiate() 24 | viewController.viewModel = setNameViewModel 25 | 26 | onBoardingViewController = BaseNavigationController(rootViewController: viewController) 27 | onBoardingViewController.navigationBar.isHidden = true 28 | onBoardingViewController.modalPresentationStyle = .fullScreen 29 | navigationController.presentOnTop(onBoardingViewController, animated: true) 30 | } 31 | 32 | private func didSetName() { 33 | let viewController = SetOptionsViewController.instantiate() 34 | viewController.viewModel = setOptionsViewModel 35 | 36 | onBoardingViewController.pushViewController(viewController, animated: true) 37 | } 38 | 39 | private func didFinishOnBoarding(with data: OnBoardingData) { 40 | dataManager.set(key: SettingKey.onBoardingData, value: data) 41 | onBoardingViewController.dismiss(animated: true, completion: nil) 42 | parentCoordinator?.didFinish(coordinator: self) 43 | } 44 | 45 | private func setUpBindings() { 46 | setNameViewModel.didTapNext 47 | .subscribe(onNext: { [weak self] in self?.didSetName() }) 48 | .disposed(by: disposeBag) 49 | 50 | setOptionsViewModel.didTapFinish 51 | .flatMapLatest { [weak self] () -> Observable in 52 | guard let self = self else { return Observable.empty() } 53 | return Observable.combineLatest( 54 | self.setNameViewModel.firstName, 55 | self.setNameViewModel.lastName, 56 | self.setOptionsViewModel.notifications, 57 | self.setOptionsViewModel.gpsTracking) 58 | .take(1) 59 | .map { OnBoardingData(firstName: $0.0, lastName: $0.1, notifications: $0.2, gpsTracking: $0.3) } 60 | } 61 | .subscribe(onNext: { [weak self] data in self?.didFinishOnBoarding(with: data) }) 62 | .disposed(by: disposeBag) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /MVVMC/App/OnBoarding/SetNameViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import RxSwift 4 | 5 | class SetNameViewController: UIViewController, Storyboarded { 6 | static var storyboard = AppStoryboard.onBoarding 7 | private let disposeBag = DisposeBag() 8 | 9 | @IBOutlet weak var firstNameTextField: LocalizedTextField! 10 | @IBOutlet weak var lastNameTextField: LocalizedTextField! 11 | @IBOutlet weak var nextButton: LocalizedButton! 12 | 13 | var viewModel: SetNameViewModel? 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | configureDismissKeyboard() 18 | setUpBindings() 19 | } 20 | 21 | func setUpBindings() { 22 | guard let viewModel = viewModel else { return } 23 | 24 | Observable.of(firstNameTextField, lastNameTextField) 25 | .flatMap { $0.rx.controlEvent(.editingDidEndOnExit) } 26 | .withLatestFrom(viewModel.isNextActive) 27 | .filter { $0 } 28 | .map { _ in Void() } 29 | .bind(to: viewModel.didTapNext) 30 | .disposed(by: disposeBag) 31 | 32 | firstNameTextField.rx.text.orEmpty 33 | .bind(to: viewModel.firstName) 34 | .disposed(by: disposeBag) 35 | 36 | lastNameTextField.rx.text.orEmpty 37 | .bind(to: viewModel.lastName) 38 | .disposed(by: disposeBag) 39 | 40 | nextButton.rx.tap 41 | .bind(to: viewModel.didTapNext) 42 | .disposed(by: disposeBag) 43 | 44 | viewModel.isNextActive 45 | .bind(to: nextButton.rx.isEnabled) 46 | .disposed(by: disposeBag) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /MVVMC/App/OnBoarding/SetNameViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | class SetNameViewModel { 5 | private let disposeBag = DisposeBag() 6 | 7 | let firstName = BehaviorSubject(value: "") 8 | let lastName = BehaviorSubject(value: "") 9 | let didTapNext = PublishSubject() 10 | let isNextActive = BehaviorSubject(value: false) 11 | 12 | init() { 13 | setUpBindings() 14 | } 15 | 16 | private func setUpBindings() { 17 | Observable 18 | .combineLatest(firstName, lastName) 19 | .map { $0.hasNonEmptyValue() && $1.hasNonEmptyValue() } 20 | .bind(to: isNextActive) 21 | .disposed(by: disposeBag) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /MVVMC/App/OnBoarding/SetOptionsViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import RxSwift 4 | 5 | class SetOptionsViewController: UIViewController, Storyboarded { 6 | static var storyboard = AppStoryboard.onBoarding 7 | private let disposeBag = DisposeBag() 8 | 9 | @IBOutlet weak var notificationsSwitch: UISwitch! 10 | @IBOutlet weak var gpsTrackingSwitch: UISwitch! 11 | @IBOutlet weak var finishButton: LocalizedButton! 12 | 13 | var viewModel: SetOptionsViewModel? 14 | 15 | override func viewDidLoad() { 16 | super.viewDidLoad() 17 | setUpBindings() 18 | } 19 | 20 | func setUpBindings() { 21 | guard let viewModel = viewModel else { return } 22 | 23 | notificationsSwitch.rx.isOn 24 | .bind(to: viewModel.notifications) 25 | .disposed(by: disposeBag) 26 | 27 | gpsTrackingSwitch.rx.isOn 28 | .bind(to: viewModel.gpsTracking) 29 | .disposed(by: disposeBag) 30 | 31 | finishButton.rx.tap 32 | .bind(to: viewModel.didTapFinish) 33 | .disposed(by: disposeBag) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /MVVMC/App/OnBoarding/SetOptionsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | class SetOptionsViewModel { 5 | let notifications = BehaviorSubject(value: true) 6 | let gpsTracking = BehaviorSubject(value: true) 7 | let didTapFinish = PublishSubject() 8 | } 9 | -------------------------------------------------------------------------------- /MVVMC/App/Settings/Settings.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 | 34 | 35 | 36 | 37 | 38 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /MVVMC/App/Settings/SettingsCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class SettingsCoordinator: BaseCoordinator { 5 | private let settingsViewModel: SettingsViewModel 6 | 7 | init(settingsViewModel: SettingsViewModel) { 8 | self.settingsViewModel = settingsViewModel 9 | } 10 | 11 | override func start() { 12 | let viewController = SettingsViewController.instantiate() 13 | viewController.viewModel = settingsViewModel 14 | 15 | navigationController.viewControllers = [viewController] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /MVVMC/App/Settings/SettingsViewController.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | import RxSwift 4 | 5 | class SettingsViewController: ViewControllerWithSideMenu, Storyboarded { 6 | static var storyboard = AppStoryboard.settings 7 | 8 | @IBOutlet weak var sendNotificationsSwitch: UISwitch! 9 | @IBOutlet weak var gpsTrackingSwitch: UISwitch! 10 | 11 | private let disposeBag = DisposeBag() 12 | var viewModel: SettingsViewModel? 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | setUpBindings() 17 | } 18 | 19 | private func setUpBindings() { 20 | guard let viewModel = viewModel else { return } 21 | 22 | title = viewModel.title 23 | 24 | viewModel.notifications 25 | .bind(to: sendNotificationsSwitch.rx.isOn) 26 | .disposed(by: disposeBag) 27 | 28 | viewModel.gpsTracking 29 | .bind(to: gpsTrackingSwitch.rx.isOn) 30 | .disposed(by: disposeBag) 31 | 32 | sendNotificationsSwitch.rx.isOn 33 | .skip(1) 34 | .bind(to: viewModel.notifications) 35 | .disposed(by: disposeBag) 36 | 37 | gpsTrackingSwitch.rx.isOn 38 | .skip(1) 39 | .bind(to: viewModel.gpsTracking) 40 | .disposed(by: disposeBag) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /MVVMC/App/Settings/SettingsViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | class SettingsViewModel { 5 | private let dataManager: DataManager 6 | private let disposeBag = DisposeBag() 7 | 8 | let title = "Settings".localized 9 | let notifications = BehaviorSubject(value: false) 10 | let gpsTracking = BehaviorSubject(value: false) 11 | 12 | init(dataManager: DataManager) { 13 | self.dataManager = dataManager 14 | 15 | guard let onBoarding = dataManager.get(key: SettingKey.onBoardingData, type: OnBoardingData.self) else { return } 16 | 17 | notifications.onNext(onBoarding.notifications) 18 | gpsTracking.onNext(onBoarding.gpsTracking) 19 | 20 | Observable.combineLatest(notifications, gpsTracking) 21 | .subscribe(onNext: { [weak self] notifications, gpsTracking in 22 | guard let onBoarding = self?.dataManager.get(key: SettingKey.onBoardingData, type: OnBoardingData.self) else { return } 23 | 24 | let newOnBoarding = OnBoardingData(firstName: onBoarding.firstName, lastName: onBoarding.lastName, 25 | notifications: notifications, gpsTracking: gpsTracking) 26 | self?.dataManager.set(key: SettingKey.onBoardingData, value: newOnBoarding) 27 | }) 28 | .disposed(by: disposeBag) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /MVVMC/App/SignIn/SignIn.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 | 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 | -------------------------------------------------------------------------------- /MVVMC/App/SignIn/SignInCoordinator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class SignInCoordinator: BaseCoordinator { 5 | private let viewModel: SignInViewModel 6 | 7 | init(viewModel: SignInViewModel) { 8 | self.viewModel = viewModel 9 | } 10 | 11 | override func start() { 12 | let viewController = SignInViewController.instantiate() 13 | viewController.viewModel = viewModel 14 | 15 | navigationController.isNavigationBarHidden = true 16 | navigationController.viewControllers = [viewController] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /MVVMC/App/SignIn/SignInViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import RxSwift 3 | import RxCocoa 4 | 5 | class SignInViewController: UIViewController, Storyboarded { 6 | static var storyboard = AppStoryboard.signIn 7 | 8 | @IBOutlet weak var usernameTextField: LocalizedTextField! 9 | @IBOutlet weak var passwordTextField: LocalizedTextField! 10 | @IBOutlet weak var signInButton: ButtonWithProgress! 11 | 12 | private let disposeBag = DisposeBag() 13 | var viewModel: SignInViewModel? 14 | 15 | override var preferredStatusBarStyle: UIStatusBarStyle { 16 | return .default 17 | } 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | configureDismissKeyboard() 22 | setUpBindings() 23 | } 24 | 25 | private func setUpBindings() { 26 | guard let viewModel = viewModel else { return } 27 | 28 | Observable.of(usernameTextField, passwordTextField) 29 | .flatMap { $0.rx.controlEvent(.editingDidEndOnExit) } 30 | .withLatestFrom(viewModel.isSignInActive) 31 | .filter { $0 } 32 | .bind { [weak self] _ in self?.viewModel?.signIn() } 33 | .disposed(by: disposeBag) 34 | 35 | usernameTextField.rx.text.orEmpty 36 | .bind(to: viewModel.email) 37 | .disposed(by: disposeBag) 38 | 39 | passwordTextField.rx.text.orEmpty 40 | .bind(to: viewModel.password) 41 | .disposed(by: disposeBag) 42 | 43 | signInButton.rx.tap 44 | .bind { [weak self] in self?.viewModel?.signIn() } 45 | .disposed(by: disposeBag) 46 | 47 | viewModel.isSignInActive 48 | .bind(to: signInButton.rx.isEnabled) 49 | .disposed(by: disposeBag) 50 | 51 | viewModel.isLoading 52 | .bind { [weak self] in 53 | guard let self = self else { return } 54 | self.usernameTextField.isEnabled = !$0 55 | self.passwordTextField.isEnabled = !$0 56 | self.signInButton.isInProgress = $0 57 | } 58 | .disposed(by: disposeBag) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /MVVMC/App/SignIn/SignInViewModel.swift: -------------------------------------------------------------------------------- 1 | import RxSwift 2 | 3 | class SignInViewModel { 4 | private let sessionService: SessionService 5 | private let disposeBag = DisposeBag() 6 | 7 | let email = BehaviorSubject(value: "") 8 | let password = BehaviorSubject(value: "") 9 | let isSignInActive = BehaviorSubject(value: false) 10 | let isLoading = BehaviorSubject(value: false) 11 | 12 | init(sessionService: SessionService) { 13 | self.sessionService = sessionService 14 | setUpBindings() 15 | } 16 | 17 | func signIn() { 18 | isLoading.onNext(true) 19 | 20 | Observable 21 | .combineLatest(email, password, isSignInActive) 22 | .take(1) 23 | .filter { _, _, active in active } 24 | .map { username, password, _ in Credentials(username: username, password: password) } 25 | .flatMapLatest { [weak self] in self?.sessionService.signIn(credentials: $0) ?? Completable.empty() } 26 | .subscribe { [weak self] _ in self?.isLoading.onNext(false) } 27 | .disposed(by: disposeBag) 28 | } 29 | 30 | private func setUpBindings() { 31 | Observable 32 | .combineLatest(email, password) 33 | .map { $0.hasNonEmptyValue() && $1.hasNonEmptyValue() } 34 | .bind(to: isSignInActive) 35 | .disposed(by: disposeBag) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /MVVMC/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Swinject 3 | import SideMenu 4 | 5 | @UIApplicationMain 6 | class AppDelegate: UIResponder, UIApplicationDelegate { 7 | var window: UIWindow? 8 | private var appCoordinator: AppCoordinator! 9 | 10 | static let container = Container() 11 | 12 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 13 | Container.loggingFunction = nil 14 | AppDelegate.container.registerDependencies() 15 | 16 | setUpSideMenu() 17 | 18 | appCoordinator = AppDelegate.container.resolve(AppCoordinator.self)! 19 | appCoordinator.start() 20 | 21 | return true 22 | } 23 | 24 | private func setUpSideMenu() { 25 | // Define the menus 26 | let leftMenuNavigationController = SideMenuNavigationController(rootViewController: DrawerMenuViewController.instantiate()) 27 | SideMenuManager.default.leftMenuNavigationController = leftMenuNavigationController 28 | leftMenuNavigationController.navigationBar.isHidden = true 29 | 30 | let style = SideMenuPresentationStyle.menuSlideIn 31 | style.backgroundColor = .black 32 | style.presentingEndAlpha = 0.32 33 | style.onTopShadowColor = .black 34 | style.onTopShadowRadius = 4.0 35 | style.onTopShadowOpacity = 0.2 36 | style.onTopShadowOffset = CGSize(width: 2.0, height: 0.0) 37 | 38 | var settings = SideMenuSettings() 39 | settings.presentationStyle = style 40 | settings.menuWidth = max(round(min((UIScreen.main.bounds.width), (UIScreen.main.bounds.height)) * 0.75), 240) 41 | settings.statusBarEndAlpha = 0.0 42 | 43 | leftMenuNavigationController.settings = settings 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /MVVMC/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 | } -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/avatar.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "avatar.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "avatar-1.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "avatar-2.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/avatar.imageset/avatar-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/avatar.imageset/avatar-1.png -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/avatar.imageset/avatar-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/avatar.imageset/avatar-2.png -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/avatar.imageset/avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/avatar.imageset/avatar.png -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/menu.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "baseline_menu_black_24pt_1x.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "baseline_menu_black_24pt_2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "baseline_menu_black_24pt_3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/menu.imageset/baseline_menu_black_24pt_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/menu.imageset/baseline_menu_black_24pt_1x.png -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/menu.imageset/baseline_menu_black_24pt_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/menu.imageset/baseline_menu_black_24pt_2x.png -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/menu.imageset/baseline_menu_black_24pt_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/menu.imageset/baseline_menu_black_24pt_3x.png -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/swift_logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "swift_logo.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "swift_logo-1.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "swift_logo-2.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/swift_logo.imageset/swift_logo-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/swift_logo.imageset/swift_logo-1.png -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/swift_logo.imageset/swift_logo-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/swift_logo.imageset/swift_logo-2.png -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/swift_logo.imageset/swift_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/swift_logo.imageset/swift_logo.png -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/tools.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "idiom" : "universal", 9 | "scale" : "2x" 10 | }, 11 | { 12 | "idiom" : "universal", 13 | "filename" : "tools.png", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /MVVMC/Assets.xcassets/tools.imageset/tools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/MVVMC/Assets.xcassets/tools.imageset/tools.png -------------------------------------------------------------------------------- /MVVMC/Constants.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | enum Constants { 5 | static let mainColor = UIColor(red: 0x71 / 0xFF, green: 0xBC / 0xFF, blue: 0xBD / 0xFF, alpha: 1) 6 | } 7 | -------------------------------------------------------------------------------- /MVVMC/Controls/ButtonWithProgress.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @IBDesignable class ButtonWithProgress: LocalizedButton { 5 | private let inProgressView = UIView() 6 | private let indicatorView = UIActivityIndicatorView(style: UIActivityIndicatorView.Style.gray) 7 | 8 | var isInProgress: Bool = false { 9 | didSet { 10 | setInProgress() 11 | } 12 | } 13 | 14 | override func awakeFromNib() { 15 | super.awakeFromNib() 16 | setUpView() 17 | } 18 | 19 | override func prepareForInterfaceBuilder() { 20 | super.prepareForInterfaceBuilder() 21 | setUpView() 22 | } 23 | 24 | private func setInProgress() { 25 | if isInProgress { 26 | inProgressView.translatesAutoresizingMaskIntoConstraints = false 27 | addSubview(inProgressView) 28 | inProgressView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true 29 | inProgressView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true 30 | inProgressView.topAnchor.constraint(equalTo: topAnchor).isActive = true 31 | inProgressView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true 32 | 33 | indicatorView.startAnimating() 34 | return 35 | } 36 | 37 | indicatorView.stopAnimating() 38 | inProgressView.removeFromSuperview() 39 | } 40 | 41 | private func setUpView() { 42 | inProgressView.clipsToBounds = true 43 | inProgressView.layer.cornerRadius = 5 44 | inProgressView.backgroundColor = .white 45 | 46 | indicatorView.translatesAutoresizingMaskIntoConstraints = false 47 | inProgressView.addSubview(indicatorView) 48 | indicatorView.centerXAnchor.constraint(equalTo: inProgressView.centerXAnchor).isActive = true 49 | indicatorView.centerYAnchor.constraint(equalTo: inProgressView.centerYAnchor).isActive = true 50 | indicatorView.heightAnchor.constraint(equalToConstant: 20).isActive = true 51 | indicatorView.widthAnchor.constraint(equalToConstant: 20).isActive = true 52 | indicatorView.contentScaleFactor = 1.5 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /MVVMC/Controls/FancyButton.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | @IBDesignable 5 | class FancyButton: UIButton { 6 | @IBInspectable var cornerRadius: CGFloat { 7 | get { 8 | return layer.cornerRadius 9 | } 10 | set { 11 | layer.cornerRadius = newValue 12 | layer.masksToBounds = newValue > 0 13 | } 14 | } 15 | 16 | @IBInspectable var borderWidth: CGFloat { 17 | get { 18 | return layer.borderWidth 19 | } 20 | set { 21 | layer.borderWidth = newValue 22 | } 23 | } 24 | 25 | @IBInspectable var borderColor: UIColor? { 26 | get { 27 | return UIColor(cgColor: layer.borderColor!) 28 | } 29 | set { 30 | layer.borderColor = newValue?.cgColor 31 | } 32 | } 33 | 34 | override open var isEnabled: Bool { 35 | didSet { 36 | alpha = isEnabled ? 1.0 : 0.4 37 | } 38 | } 39 | 40 | override open var isHighlighted: Bool { 41 | didSet { 42 | setBackgroundColor() 43 | } 44 | } 45 | 46 | override var buttonType: UIButton.ButtonType { 47 | return UIButton.ButtonType.custom 48 | } 49 | 50 | override var contentEdgeInsets: UIEdgeInsets { 51 | get { 52 | return UIEdgeInsets(top: 9, left: 40, bottom: 9, right: 40) 53 | } 54 | 55 | set {} 56 | } 57 | 58 | var highlightedColor: UIColor { 59 | return Constants.mainColor 60 | } 61 | 62 | override func awakeFromNib() { 63 | super.awakeFromNib() 64 | setUpView() 65 | } 66 | 67 | override func prepareForInterfaceBuilder() { 68 | super.prepareForInterfaceBuilder() 69 | setUpView() 70 | } 71 | 72 | private func setUpView() { 73 | cornerRadius = 10 74 | borderWidth = 1 75 | borderColor = tintColor 76 | 77 | setTitleColor(UIColor.white, for: .highlighted) 78 | alpha = isEnabled ? 1.0 : 0.3 79 | setBackgroundColor() 80 | 81 | layoutIfNeeded() 82 | } 83 | 84 | private func setBackgroundColor() { 85 | backgroundColor = isHighlighted 86 | ? highlightedColor 87 | : UIColor.clear 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /MVVMC/Controls/LocalizedButton.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class LocalizedButton: FancyButton { 5 | @IBInspectable var localizationKey: String? 6 | @IBInspectable var upperText: Bool = false 7 | 8 | override func awakeFromNib() { 9 | super.awakeFromNib() 10 | 11 | if localizationKey == nil { 12 | assertionFailure("Translation key not set for \(title(for: .normal) ?? "")") 13 | } 14 | setTitle(upperText ? localizationKey?.localizedUpper : localizationKey?.localized) 15 | } 16 | 17 | private func setTitle(_ title: String?) { 18 | UIView.performWithoutAnimation { 19 | setTitle(title, for: .normal) 20 | layoutIfNeeded() 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /MVVMC/Controls/LocalizedLabel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class LocalizedLabel: UILabel { 5 | @IBInspectable var localizationKey: String? 6 | @IBInspectable var upperText: Bool = false 7 | 8 | override func awakeFromNib() { 9 | super.awakeFromNib() 10 | 11 | if localizationKey == nil { 12 | assertionFailure("Translation key not set for \(text ?? "")") 13 | } 14 | text = upperText ? localizationKey?.localizedUpper : localizationKey?.localized 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /MVVMC/Controls/LocalizedTextField.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class LocalizedTextField: UITextField { 5 | @IBInspectable var placeholderLocalizationKey: String? 6 | @IBInspectable var upperText: Bool = false 7 | 8 | override func awakeFromNib() { 9 | super.awakeFromNib() 10 | 11 | if (placeholder?.hasNonEmptyValue() ?? false) && placeholderLocalizationKey == nil { 12 | assertionFailure("Translation key not set for \(text ?? "")") 13 | } 14 | placeholder = upperText 15 | ? placeholderLocalizationKey?.localizedUpper ?? placeholder 16 | : placeholderLocalizationKey?.localized ?? placeholder 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /MVVMC/DependencyInjection/Container+Coordinators.swift: -------------------------------------------------------------------------------- 1 | import Swinject 2 | 3 | extension Container { 4 | func registerCoordinators() { 5 | autoregister(AppCoordinator.self, initializer: AppCoordinator.init) 6 | autoregister(SignInCoordinator.self, initializer: SignInCoordinator.init) 7 | autoregister(DrawerMenuCoordinator.self, initializer: DrawerMenuCoordinator.init) 8 | autoregister(DashboardCoordinator.self, initializer: DashboardCoordinator.init) 9 | autoregister(OnBoardingCoordinator.self, initializer: OnBoardingCoordinator.init) 10 | autoregister(SettingsCoordinator.self, initializer: SettingsCoordinator.init) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /MVVMC/DependencyInjection/Container+RegisterDependencies.swift: -------------------------------------------------------------------------------- 1 | import Swinject 2 | 3 | extension Container { 4 | func registerDependencies() { 5 | registerServices() 6 | registerCoordinators() 7 | registerViewModels() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /MVVMC/DependencyInjection/Container+Services.swift: -------------------------------------------------------------------------------- 1 | import Swinject 2 | import SwinjectAutoregistration 3 | 4 | extension Container { 5 | func registerServices() { 6 | autoregister(DataManager.self, initializer: UserDataManager.init).inObjectScope(.container) 7 | autoregister(BackendRestClient.self, initializer: BackendRestClient.init).inObjectScope(.container) 8 | autoregister(AlertDispatcher.self, initializer: AlertDispatcher.init).inObjectScope(.container) 9 | autoregister(SessionService.self, initializer: SessionService.init).inObjectScope(.container) 10 | autoregister(TranslationsService.self, initializer: TranslationsService.init).inObjectScope(.container) 11 | autoregister(HttpClient.self, initializer: HttpClientMock.init).inObjectScope(.container) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /MVVMC/DependencyInjection/Container+ViewModels.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Swinject 3 | import SwinjectAutoregistration 4 | 5 | extension Container { 6 | func registerViewModels() { 7 | autoregister(SignInViewModel.self, initializer: SignInViewModel.init) 8 | autoregister(DrawerMenuViewModel.self, initializer: DrawerMenuViewModel.init) 9 | autoregister(DashboardViewModel.self, initializer: DashboardViewModel.init) 10 | autoregister(SettingsViewModel.self, initializer: SettingsViewModel.init) 11 | 12 | autoregister(SetNameViewModel.self, initializer: SetNameViewModel.init) 13 | autoregister(SetOptionsViewModel.self, initializer: SetOptionsViewModel.init) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /MVVMC/Extensions/ApiResponse+Print.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension ApiResponse { 4 | func print() { 5 | let responseJson = data != nil ? String(data: data!, encoding: .utf8) ?? "" : "" 6 | 7 | var message = "response: \(method) \(requestUrl) (status: \(statusCode ?? -1))" 8 | if responseJson.count > 0 { 9 | message.append("\n\n\(responseJson.prefix(1000))\n") 10 | } 11 | 12 | if error == nil { 13 | Logger.debug(message) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /MVVMC/Extensions/Data+Json.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | func toObject(_ type: T.Type) -> T? { 5 | if type == VoidResponse.self { 6 | return VoidResponse() as? T 7 | } 8 | return try? Json.decoder.decode(type, from: self) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /MVVMC/Extensions/Encodable+Json.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Encodable { 4 | func toJson() -> Data? { 5 | return try? Json.encoder.encode(self) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /MVVMC/Extensions/String+Localization.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | var localized: String { 5 | return LocalizationUtils.localize(key: self) 6 | } 7 | 8 | var localizedUpper: String { 9 | return LocalizationUtils.localize(key: self).uppercased() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /MVVMC/Extensions/String+Trim.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | func trim() -> String { 5 | return trimmingCharacters(in: CharacterSet.whitespaces) 6 | } 7 | 8 | func hasNonEmptyValue() -> Bool { 9 | return trim() != "" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /MVVMC/Extensions/UIViewControllerExtensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | extension UIViewController { 5 | func configureDismissKeyboard() { 6 | view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTap(sender:)))) 7 | } 8 | 9 | @objc private func handleTap(sender: UITapGestureRecognizer? = nil) { 10 | sender?.view?.endEditing(true) 11 | } 12 | 13 | func presentOnTop(_ viewController: UIViewController, animated: Bool) { 14 | var topViewController = self 15 | while let presentedViewController = topViewController.presentedViewController { 16 | topViewController = presentedViewController 17 | } 18 | topViewController.present(viewController, animated: animated) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /MVVMC/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | MVVM-C Demo 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | 35 | UISupportedInterfaceOrientations~ipad 36 | 37 | UIInterfaceOrientationPortrait 38 | UIInterfaceOrientationPortraitUpsideDown 39 | UIInterfaceOrientationLandscapeLeft 40 | UIInterfaceOrientationLandscapeRight 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /MVVMC/Models/AlertMessage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AlertMessage: Equatable { 4 | static func == (lhs: AlertMessage, rhs: AlertMessage) -> Bool { 5 | return lhs.title == rhs.title && lhs.message == rhs.message 6 | } 7 | 8 | let title: String 9 | let message: String 10 | let buttons: [String] 11 | let actions: [String:() -> Void] 12 | } 13 | -------------------------------------------------------------------------------- /MVVMC/Models/AppStoryboard.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum AppStoryboard: String { 4 | case signIn = "SignIn" 5 | case drawer = "Drawer" 6 | case dashboard = "Dashboard" 7 | case onBoarding = "OnBoarding" 8 | case settings = "Settings" 9 | } 10 | -------------------------------------------------------------------------------- /MVVMC/Models/Credentials.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Credentials { 4 | let username: String 5 | let password: String 6 | } 7 | -------------------------------------------------------------------------------- /MVVMC/Models/DrawerMenuScreen.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum DrawerMenuScreen: Int { 4 | case dashboard 5 | case settings 6 | case signOut 7 | } 8 | -------------------------------------------------------------------------------- /MVVMC/Models/OnBoardingData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct OnBoardingData: Codable { 4 | let firstName: String 5 | let lastName: String 6 | let notifications: Bool 7 | let gpsTracking: Bool 8 | } 9 | -------------------------------------------------------------------------------- /MVVMC/Models/Session.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Session: Codable, Equatable { 4 | private(set) var token: Token 5 | private(set) var email: String 6 | private(set) var profile: MeResponse 7 | 8 | mutating func updateDetails(_ data: MeResponse) { 9 | profile = data 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /MVVMC/Models/SettingKey.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SettingKey { 4 | static let session = "session" 5 | static let translations = "translations" 6 | static let onBoardingData = "onBoardingData" 7 | } 8 | -------------------------------------------------------------------------------- /MVVMC/Models/Token.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Token: Codable, Equatable { 4 | let token: String 5 | let tokenType: String 6 | 7 | func getToken() -> String { 8 | return "\(tokenType) \(token)" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /MVVMC/Networking/ApiEndpoints/GeneralEndpoints.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum GeneralEndpoints { 4 | class FetchTranslations: ApiRequest { 5 | init() { 6 | super.init(resource: "translations") 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /MVVMC/Networking/ApiEndpoints/SessionEndpoints.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum SessionEndpoints { 4 | class SignIn: ApiRequest { 5 | init(credentials: Credentials) { 6 | super.init(resource: "login", 7 | method: .post, 8 | json: SignInRequest(username: credentials.username, password: credentials.password).toJson()) 9 | } 10 | } 11 | 12 | class SignOut: ApiRequest { 13 | init() { 14 | super.init(resource: "logout", 15 | method: .post) 16 | } 17 | } 18 | 19 | class FetchMe: ApiRequest { 20 | init() { 21 | super.init(resource: "me") 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /MVVMC/Networking/ApiEndpoints/TasksEndpoints.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum TasksEndpoints { 4 | class FetchTasks: ApiRequest<[String]> { 5 | init() { 6 | super.init(resource: "tasks") 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /MVVMC/Networking/BackendRestClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | class BackendRestClient { 5 | private let httpClient: HttpClient 6 | private let alertDispatcher: AlertDispatcher 7 | private lazy var sessionService: SessionService? = AppDelegate.container.resolve(SessionService.self) 8 | 9 | private var token: String? { 10 | return sessionService?.sessionState?.token.getToken() 11 | } 12 | 13 | init(httpClient: HttpClient, alertDispatcher: AlertDispatcher) { 14 | self.httpClient = httpClient 15 | self.alertDispatcher = alertDispatcher 16 | } 17 | 18 | func request(_ request: ApiRequest) -> Single{ 19 | return Single.create { [weak self] single in 20 | guard let self = self else { return Disposables.create() } 21 | 22 | self.httpClient.set(headers: self.getHeaders()) 23 | self.httpClient.request( 24 | resource: request.resource, 25 | method: request.method, 26 | json: request.json) 27 | { 28 | self.validate(response: $0, for: request, single: single) 29 | } 30 | 31 | return Disposables.create() 32 | } 33 | } 34 | 35 | private func validate(response: ApiResponse, for request: ApiRequest, single: Single.SingleObserver) { 36 | response.print() 37 | 38 | guard response.success && response.statusCode == request.expectedCode else { 39 | Logger.error("Unsuccessful request", error: response.error) 40 | let error = ApiError.requestFailed(statusCode: response.statusCode, response: response.data) 41 | dispatch(error: error) 42 | single(.error(error)) 43 | return 44 | } 45 | 46 | guard let parsedResponse = response.data?.toObject(T.self) else { 47 | Logger.error("Could not parse response") 48 | let error = ApiError.requestFailed(statusCode: response.statusCode, response: response.data) 49 | dispatch(error: error) 50 | single(.error(error)) 51 | return 52 | } 53 | 54 | single(.success(parsedResponse)) 55 | } 56 | 57 | private func getHeaders() -> [String:String] { 58 | var headers = ["Content-Type": "application/json"] 59 | if let tokenHeader = token { 60 | headers["Authorization"] = tokenHeader 61 | } 62 | return headers 63 | } 64 | 65 | private func dispatch(error: ApiError) { 66 | let message = getMessage(error: error) 67 | alertDispatcher.dispatch(error: message) 68 | } 69 | 70 | private func getMessage(error: ApiError) -> AlertMessage { 71 | var message = "Could not process request." 72 | 73 | if case .requestFailed(_, let response) = error, 74 | let errorResponse = response?.toObject(ErrorResponse.self)?.errorCode?.localized { 75 | message = errorResponse 76 | } 77 | 78 | let alertMessage = AlertMessage(title: "Error", message: message, buttons: ["OK"], actions: [:]) 79 | return alertMessage 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /MVVMC/Networking/DTO/Request/SignInRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SignInRequest: Codable { 4 | let username: String 5 | let password: String 6 | } 7 | -------------------------------------------------------------------------------- /MVVMC/Networking/DTO/Response/ErrorResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ErrorResponse: Codable { 4 | var errorCode: String? 5 | } 6 | -------------------------------------------------------------------------------- /MVVMC/Networking/DTO/Response/MeResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct MeResponse: Codable, Equatable { 4 | let userId: String 5 | } 6 | -------------------------------------------------------------------------------- /MVVMC/Networking/DTO/Response/SignInResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct SignInResponse: Codable { 4 | var accessToken: String? 5 | var tokenType: String? 6 | } 7 | -------------------------------------------------------------------------------- /MVVMC/Networking/DTO/Response/TranslationsResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | typealias Languages = [String:[String:String]] 4 | 5 | struct TranslationsResponse: Codable { 6 | let languages: Languages 7 | } 8 | -------------------------------------------------------------------------------- /MVVMC/Networking/Models/ApiError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum ApiError: Error { 4 | case requestFailed(statusCode: Int?, response: Data?) 5 | } 6 | -------------------------------------------------------------------------------- /MVVMC/Networking/Models/ApiRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class ApiRequest { 4 | let method: HttpMethod 5 | let resource: String 6 | let expectedCode: Int 7 | let form: [String:String]? 8 | let json: Data? 9 | 10 | init(resource: String, 11 | method: HttpMethod = .get, 12 | expectedCode: Int = 200, 13 | form: [String:String]? = nil, 14 | json: Data? = nil 15 | ) { 16 | self.resource = resource 17 | self.method = method 18 | self.expectedCode = expectedCode 19 | self.form = form 20 | self.json = json 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /MVVMC/Networking/Models/ApiResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct VoidResponse: Codable { } 4 | 5 | struct ApiResponse { 6 | let success: Bool 7 | let statusCode: Int? 8 | 9 | let requestUrl: String 10 | let method: HttpMethod 11 | 12 | let data: Data? 13 | let error: Error? 14 | 15 | init(success: Bool, statusCode: Int?, requestUrl: String, method: HttpMethod, data: Data?, error: Error?) { 16 | self.success = success 17 | self.statusCode = statusCode 18 | self.requestUrl = requestUrl 19 | self.method = method 20 | self.data = data 21 | self.error = error 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /MVVMC/Networking/Models/HttpMethod.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HttpMethod: String { 4 | case post = "POST" 5 | case get = "GET" 6 | case delete = "DELETE" 7 | case put = "PUT" 8 | } 9 | -------------------------------------------------------------------------------- /MVVMC/Networking/Models/HttpStatusCode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HttpStatusCode: Int { 4 | case success = 200 5 | case forbidden = 403 6 | } 7 | -------------------------------------------------------------------------------- /MVVMC/Protocols/DataManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol DataManager { 4 | func get(key: String, type: T.Type) -> T? where T : Codable 5 | func get(key: String) -> String? 6 | func set(key: String, value: T?) where T : Codable 7 | func remove(key: String) 8 | func clear() 9 | } 10 | -------------------------------------------------------------------------------- /MVVMC/Protocols/HttpClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol HttpClient { 4 | func set(headers: [String:String]) 5 | func request(resource: String, method: HttpMethod, json: Data?, 6 | completion: @escaping (ApiResponse) -> Void) 7 | } 8 | -------------------------------------------------------------------------------- /MVVMC/Protocols/Storyboarded.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | protocol Storyboarded { 5 | static var storyboard: AppStoryboard { get } 6 | static func instantiate() -> Self 7 | } 8 | 9 | extension Storyboarded { 10 | static func instantiate() -> Self { 11 | let identifier = String(describing: self) 12 | let uiStoryboard = UIStoryboard(name: storyboard.rawValue, bundle: nil) 13 | let viewController = uiStoryboard.instantiateViewController(withIdentifier: identifier) as! Self 14 | 15 | return viewController 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /MVVMC/Services/AlertDispatcher.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | class AlertDispatcher { 5 | private var lastError: AlertMessage? 6 | 7 | func dispatch(error: AlertMessage) { 8 | guard lastError != error else { return } 9 | lastError = error 10 | 11 | if let viewController = UIApplication.shared.keyWindow?.rootViewController { 12 | showAlert(on: viewController, error: error) 13 | } 14 | } 15 | 16 | private func showAlert(on viewController: UIViewController, error: AlertMessage) { 17 | let alert = UIAlertController(title: error.title.localized, message: error.message.localized, preferredStyle: .alert) 18 | 19 | for button in error.buttons { 20 | let alertAction = UIAlertAction(title: button.localized, style: .default) { [weak self] _ in 21 | error.actions[button]?() 22 | self?.lastError = nil 23 | } 24 | 25 | alert.addAction(alertAction) 26 | } 27 | viewController.presentOnTop(alert, animated: true) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /MVVMC/Services/SessionService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | class SessionService { 5 | enum SessionError: Error { 6 | case invalidToken 7 | } 8 | 9 | // MARK: - Private fields 10 | 11 | private let dataManager: DataManager 12 | private let restClient: BackendRestClient 13 | private let translationsService: TranslationsService 14 | 15 | private let signOutSubject = PublishSubject() 16 | private let signInSubject = PublishSubject() 17 | private let disposeBag = DisposeBag() 18 | 19 | private var token: Token? 20 | 21 | // MARK: - Public properties 22 | 23 | private(set) var sessionState: Session? 24 | 25 | var didSignOut: Observable { 26 | return signOutSubject.asObservable() 27 | } 28 | var didSignIn: Observable { 29 | return signInSubject.asObservable() 30 | } 31 | 32 | // MARK: - Public Methods 33 | 34 | init(dataManager: DataManager, restClient: BackendRestClient, translationsService: TranslationsService) { 35 | self.dataManager = dataManager 36 | self.restClient = restClient 37 | self.translationsService = translationsService 38 | 39 | loadSession() 40 | } 41 | 42 | func signIn(credentials: Credentials) -> Completable { 43 | let signIn = restClient.request(SessionEndpoints.SignIn(credentials: credentials)) 44 | let fetchMe = restClient.request(SessionEndpoints.FetchMe()) 45 | 46 | return translationsService.fetchTranslations() 47 | .andThen(signIn) 48 | .do(onSuccess: { [weak self] in try self?.setToken(response: $0) }) 49 | .flatMap { _ in fetchMe } 50 | .do(onSuccess: { [weak self] in try self?.setSession(credentials: credentials, response: $0) }) 51 | .asCompletable() 52 | } 53 | 54 | func signOut() -> Completable { 55 | let signOut = restClient.request(SessionEndpoints.SignOut()) 56 | 57 | return signOut 58 | .do(onSuccess: { [weak self] _ in self?.removeSession() }) 59 | .asCompletable() 60 | } 61 | 62 | func refreshProfile() -> Single { 63 | let fetchMe = restClient.request(SessionEndpoints.FetchMe()) 64 | 65 | return fetchMe 66 | .do(onSuccess: { [weak self] in self?.updateProfile(data: $0) }) 67 | } 68 | 69 | // MARK: - Session Management 70 | 71 | private func setToken(response: SignInResponse) throws { 72 | guard let accessToken = response.accessToken, let tokenType = response.tokenType else { 73 | throw SessionError.invalidToken 74 | } 75 | 76 | token = Token(token: accessToken, tokenType: tokenType) 77 | } 78 | 79 | private func setSession(credentials: Credentials, response: MeResponse) throws { 80 | guard let token = token else { 81 | throw SessionError.invalidToken 82 | } 83 | 84 | sessionState = Session( 85 | token: token, 86 | email: credentials.username.lowercased(), 87 | profile: response) 88 | dataManager.set(key: SettingKey.session, value: sessionState) 89 | 90 | signInSubject.onNext(Void()) 91 | } 92 | 93 | private func loadSession() { 94 | sessionState = dataManager.get(key: SettingKey.session, type: Session.self) 95 | } 96 | 97 | private func removeSession() { 98 | dataManager.clear() 99 | token = nil 100 | sessionState = nil 101 | signOutSubject.onNext(Void()) 102 | } 103 | 104 | private func updateProfile(data: MeResponse) { 105 | sessionState?.updateDetails(data) 106 | dataManager.set(key: SettingKey.session, value: sessionState) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /MVVMC/Services/TranslationsService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RxSwift 3 | 4 | class TranslationsService { 5 | private let dataManager: DataManager 6 | private let restClient: BackendRestClient 7 | 8 | private var translations: Languages? 9 | private(set) var currentLocale: Locale! 10 | 11 | init(dataManager: DataManager, restClient: BackendRestClient) { 12 | self.dataManager = dataManager 13 | self.restClient = restClient 14 | loadTranslations() 15 | } 16 | 17 | func loadTranslations() { 18 | translations = dataManager.get(key: SettingKey.translations, type: Languages.self) ?? loadDefaultTranslations() 19 | currentLocale = getLocale() 20 | Logger.info("Current locale: \(currentLocale.identifier)") 21 | Logger.info("Loaded languages: \(translations?.count ?? 0)") 22 | } 23 | 24 | func fetchTranslations() -> Completable { 25 | let request = restClient.request(GeneralEndpoints.FetchTranslations()) 26 | 27 | return request 28 | .do(onSuccess: { [weak self] response in 29 | self?.dataManager.set(key: SettingKey.translations, value: response.languages) 30 | self?.loadTranslations() 31 | }) 32 | .asCompletable() 33 | } 34 | 35 | func getCurrentTranslations() -> [String:String]? { 36 | let localeId = currentLocale.identifier.replacingOccurrences(of: "_", with: "-") 37 | let translations = self.translations?[localeId] 38 | return translations 39 | } 40 | 41 | private func getLocale() -> Locale { 42 | if let preferred = Locale.preferredLanguages 43 | .first(where: { translations?[$0.replacingOccurrences(of: "_", with: "-")] != nil }) { 44 | 45 | return Locale(identifier: preferred) 46 | } 47 | 48 | return Locale(identifier: "en_GB") 49 | } 50 | 51 | private func loadDefaultTranslations() -> Languages { 52 | if let json = FileUtils.loadTextFile(with: "translations", ofType: "json"), 53 | let data = json.data(using: .utf8), 54 | let translations = data.toObject(TranslationsResponse.self) { 55 | 56 | return translations.languages 57 | } 58 | 59 | return [:] 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /MVVMC/Services/UserDataManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class UserDataManager: DataManager { 4 | private var data: [String:String] 5 | 6 | init() { 7 | data = UserDefaults.standard 8 | .data(forKey: "data")? 9 | .toObject([String:String].self) ?? [:] 10 | 11 | Logger.info("Loaded user data: \(data.count) keys") 12 | } 13 | 14 | func get(key: String, type: T.Type) -> T? where T : Codable { 15 | let result = data[key]?.data(using: .utf8)?.toObject(type) 16 | return result 17 | } 18 | 19 | func get(key: String) -> String? { 20 | return data[key] 21 | } 22 | 23 | func set(key: String, value: T?) where T : Codable { 24 | if let json = value?.toJson() { 25 | data[key] = String(data: json, encoding: .utf8) 26 | synchronize() 27 | } 28 | } 29 | 30 | func remove(key: String) { 31 | data.removeValue(forKey: key) 32 | synchronize() 33 | } 34 | 35 | func clear() { 36 | data.removeAll() 37 | synchronize() 38 | } 39 | 40 | private func synchronize() { 41 | UserDefaults.standard.set(data.toJson(), forKey: "data") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /MVVMC/Utils/FileUtils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct FileUtils { 4 | static func loadTextFile(with name: String, ofType type: String) -> String? { 5 | guard let path = Bundle.main.path(forResource: name, ofType: type) else { return nil } 6 | 7 | do { 8 | let content = try String(contentsOfFile: path, encoding: .utf8) 9 | return content 10 | } catch { 11 | return nil 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /MVVMC/Utils/Json.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Json { 4 | static let encoder: JSONEncoder = { 5 | let encoder = JSONEncoder() 6 | encoder.outputFormatting = .prettyPrinted 7 | return encoder 8 | }() 9 | 10 | static let decoder = JSONDecoder() 11 | } 12 | -------------------------------------------------------------------------------- /MVVMC/Utils/LocalizationUtils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum LocalizationUtils { 4 | private static var translationsService = AppDelegate.container.resolve(TranslationsService.self)! 5 | 6 | static var currentLocale: Locale { 7 | return translationsService.currentLocale 8 | } 9 | 10 | static func localize(key: String) -> String { 11 | return translationsService.getCurrentTranslations()?[key] ?? key 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /MVVMC/Utils/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class Logger { 4 | enum LogLevel: Int { 5 | case none 6 | case error 7 | case info 8 | case debug 9 | } 10 | 11 | static var logLevel = LogLevel.debug 12 | 13 | static func debug(_ message: String) { 14 | guard logLevel.rawValue >= LogLevel.debug.rawValue else { return } 15 | log("\(getDate()) | DEBUG | \(message)") 16 | } 17 | 18 | static func info(_ message: String) { 19 | guard logLevel.rawValue >= LogLevel.info.rawValue else { return } 20 | log("\(getDate()) | INFO | \(message)") 21 | } 22 | 23 | static func error(_ message: String) { 24 | guard logLevel.rawValue >= LogLevel.error.rawValue else { return } 25 | log("\(getDate()) | ERROR | \(message)") 26 | } 27 | 28 | static func error(_ message: String, error: Error?) { 29 | guard logLevel.rawValue >= LogLevel.error.rawValue else { return } 30 | if error != nil { 31 | log("\(getDate()) | ERROR | \(message)\n\(error!)") 32 | } else { 33 | log("\(getDate()) | ERROR | \(message)") 34 | } 35 | } 36 | 37 | private static func log(_ message: String) { 38 | #if DEBUG 39 | print(message) 40 | #endif 41 | } 42 | 43 | private static func getDate() -> String { 44 | let formatter = DateFormatter() 45 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 46 | return formatter.string(from: Date()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /MVVMC/Utils/ViewControllerUtils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | enum ViewControllerUtils { 5 | static func setRootViewController(window: UIWindow, viewController: UIViewController, withAnimation: Bool) { 6 | if !withAnimation { 7 | window.rootViewController = viewController 8 | window.makeKeyAndVisible() 9 | return 10 | } 11 | 12 | if let snapshot = window.snapshotView(afterScreenUpdates: true) { 13 | viewController.view.addSubview(snapshot) 14 | window.rootViewController = viewController 15 | window.makeKeyAndVisible() 16 | 17 | UIView.animate(withDuration: 0.4, animations: { 18 | snapshot.layer.opacity = 0 19 | }, completion: { _ in 20 | snapshot.removeFromSuperview() 21 | }) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /MVVMC/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "languages": { 3 | "pl-PL": { 4 | "Dashboard": "[pl] Dashboard", 5 | "SignIn": "[pl] Sign In", 6 | "Email": "[pl] E-mail", 7 | "Password": "[pl] Password", 8 | "Settings": "[pl] Settings", 9 | "SignOut": "[pl] Sign Out", 10 | "Error": "[pl] Error", 11 | "Back": "[pl] Back", 12 | "YourTasksForToday": "[pl] Your tasks for today:", 13 | "FillYourName": "[pl] Fill Your Name", 14 | "AdjustSettings": "[pl] Adjust Settings", 15 | "EnableGpsTracking": "[pl] Enable Gps Tracking", 16 | "SendNotifications": "[pl] Send Notifications", 17 | "FirstName": "[pl] First name", 18 | "LastName": "[pl] Last name", 19 | "Next": "[pl] Next", 20 | "Finish": "[pl] Finish", 21 | "InvalidCredentials": "[pl] Username or password is incorrect. Try this password: pass", 22 | "OK": "[pl] OK" 23 | }, 24 | "en": { 25 | "Dashboard": "Dashboard", 26 | "SignIn": "Sign In", 27 | "Email": "E-mail", 28 | "Password": "Password", 29 | "Settings": "Settings", 30 | "SignOut": "Sign Out", 31 | "Error": "Error", 32 | "Back": "Back", 33 | "YourTasksForToday": "Your tasks for today:", 34 | "FillYourName": "Fill Your Name", 35 | "AdjustSettings": "Adjust Settings", 36 | "EnableGpsTracking": "Enable Gps Tracking", 37 | "SendNotifications": "Send Notifications", 38 | "FirstName": "First name", 39 | "LastName": "Last name", 40 | "Next": "Next", 41 | "Finish": "Finish", 42 | "InvalidCredentials": "Username or password is incorrect. Try this password: pass", 43 | "OK": "OK" 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /MVVMCTests/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 | -------------------------------------------------------------------------------- /MVVMCTests/MVVMCTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MVVMCTests.swift 3 | // MVVMCTests 4 | // 5 | // Created by Wojciech Kulik on 13/07/2019. 6 | // Copyright © 2019 Wojciech Kulik. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import MVVMC 11 | 12 | class MVVMCTests: XCTestCase { 13 | 14 | override func setUp() { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | } 17 | 18 | override func tearDown() { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() { 23 | // This is an example of a functional test case. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testPerformanceExample() { 28 | // This is an example of a performance test case. 29 | self.measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /MVVMCUITests/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 | -------------------------------------------------------------------------------- /MVVMCUITests/MVVMCUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MVVMCUITests.swift 3 | // MVVMCUITests 4 | // 5 | // Created by Wojciech Kulik on 13/07/2019. 6 | // Copyright © 2019 Wojciech Kulik. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class MVVMCUITests: XCTestCase { 12 | 13 | override func setUp() { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | 16 | // In UI tests it is usually best to stop immediately when a failure occurs. 17 | continueAfterFailure = false 18 | 19 | // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. 20 | XCUIApplication().launch() 21 | 22 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 23 | } 24 | 25 | override func tearDown() { 26 | // Put teardown code here. This method is called after the invocation of each test method in the class. 27 | } 28 | 29 | func testExample() { 30 | // Use recording to get started writing UI tests. 31 | // Use XCTAssert and related functions to verify your tests produce the correct results. 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Mocks/HttpClientMock.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class HttpClientMock: HttpClient { 4 | func set(headers: [String : String]) {} 5 | 6 | func request(resource: String, method: HttpMethod, json: Data?, 7 | completion: @escaping (ApiResponse) -> Void) { 8 | 9 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { 10 | if resource == "login" { 11 | if let json = json, let request = json.toObject(SignInRequest.self), 12 | request.username.count > 0, 13 | request.password == "pass" { 14 | completion(self.getResponse(200, resource, method, "{ \"accessToken\": \"12345678\", \"tokenType\": \"bearer\" }")) 15 | } else { 16 | completion(self.getResponse(403, resource, method, "{ \"errorCode\": \"InvalidCredentials\" }")) 17 | } 18 | } else if resource == "me" { 19 | completion(self.getResponse(200, resource, method, "{ \"userId\": \"1234-2131-1234\" }")) 20 | } else if resource == "logout" { 21 | completion(self.getResponse(200, resource, method, "")) 22 | } else if resource == "translations" { 23 | completion(self.getResponse(200, resource, method, FileUtils.loadTextFile(with: "translations", ofType: "json"))) 24 | } else if resource == "tasks" { 25 | completion(self.getResponse(200, resource, method, "[\"Study RxSwift\", \"Read about MVVM\", \"Read about Coordinators\", \"Create sample project\"]")) 26 | } 27 | } 28 | } 29 | 30 | private func getResponse(_ code: Int, _ resource: String, _ method: HttpMethod, _ data: String?) -> ApiResponse { 31 | return ApiResponse( 32 | success: true, 33 | statusCode: code, 34 | requestUrl: "https://mock/\(resource)", 35 | method: method, 36 | data: data?.data(using: .utf8), 37 | error: nil) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '12.2' 2 | 3 | target 'MVVMC' do 4 | use_frameworks! 5 | 6 | pod 'RxSwift' 7 | pod 'RxCocoa' 8 | pod 'SideMenu' 9 | pod 'Swinject' 10 | pod 'SwinjectAutoregistration' 11 | pod 'SwiftLint' 12 | 13 | target 'MVVMCTests' do 14 | inherit! :search_paths 15 | # Pods for testing 16 | end 17 | 18 | target 'MVVMCUITests' do 19 | # Pods for testing 20 | end 21 | end 22 | 23 | post_install do |installer| 24 | installer.pods_project.targets.each do |target| 25 | target.build_configurations.each do |config| 26 | config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.2' 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - RxCocoa (5.1.1): 3 | - RxRelay (~> 5) 4 | - RxSwift (~> 5) 5 | - RxRelay (5.1.1): 6 | - RxSwift (~> 5) 7 | - RxSwift (5.1.1) 8 | - SideMenu (6.5.0) 9 | - SwiftLint (0.40.3) 10 | - Swinject (2.7.1) 11 | - SwinjectAutoregistration (2.7.0): 12 | - Swinject (~> 2.7) 13 | 14 | DEPENDENCIES: 15 | - RxCocoa 16 | - RxSwift 17 | - SideMenu 18 | - SwiftLint 19 | - Swinject 20 | - SwinjectAutoregistration 21 | 22 | SPEC REPOS: 23 | trunk: 24 | - RxCocoa 25 | - RxRelay 26 | - RxSwift 27 | - SideMenu 28 | - SwiftLint 29 | - Swinject 30 | - SwinjectAutoregistration 31 | 32 | SPEC CHECKSUMS: 33 | RxCocoa: 32065309a38d29b5b0db858819b5bf9ef038b601 34 | RxRelay: d77f7d771495f43c556cbc43eebd1bb54d01e8e9 35 | RxSwift: 81470a2074fa8780320ea5fe4102807cb7118178 36 | SideMenu: f583187d21c5b1dd04c72002be544b555a2627a2 37 | SwiftLint: dfd554ff0dff17288ee574814ccdd5cea85d76f7 38 | Swinject: ddf78b8486dd9b71a667b852cad919ab4484478e 39 | SwinjectAutoregistration: 330f5012642a8b5c89a8a4adb0c5e52df07382c0 40 | 41 | PODFILE CHECKSUM: 7f1a22a7eb2a89e3576a073b323b067868196be2 42 | 43 | COCOAPODS: 1.9.3 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![BuyMeACoffee](https://www.buymeacoffee.com/assets/img/guidelines/download-assets-sm-2.svg)](https://www.buymeacoffee.com/WojciechKulik) 2 | 3 | ## Swift-MVVMC-Demo 4 | Sample iOS application in Swift presenting usage of MVVM-C pattern. 5 | 6 | ![](https://github.com/wojciech-kulik/Swift-MVVMC-Demo/blob/master/screenshots.png) 7 | 8 | If you want to check out just a simple MVVM-C pattern without extra features, please see this repository: [Swift-MVVMC-SimpleExample](https://github.com/wojciech-kulik/Swift-MVVMC-SimpleExample) 9 | 10 | ## Application Features 11 | - Sign in screen 12 | - Dashboard with sample data fetched from mocked backend 13 | - Onboarding displayed after login 14 | - Settings stored locally 15 | - Drawer menu with: dashboard, settings, sing out 16 | 17 | ## Implementation features 18 | - Dendency Injection using `Swinject` 19 | - Session management 20 | - Translations fetched from backend 21 | - UI controls with settable translations on Storyboard 22 | - Api endpoints defined in OOP manner by subclassing `BaseApiRequest` 23 | - Logging 24 | 25 | ## Architecture 26 | This project is POC for MVVM-C pattern where: 27 | - View is represented by `UIViewController` designed in Storyboard 28 | - Model represents state and domain objects 29 | - ViewModel interacts with Model and prepares data to be displayed. View uses ViewModel's data either directly or through bindings (using RxSwift) to configure itself. View also notifies ViewModel about user actions like button tap. 30 | - Coordinator is responsible for handling application flow, decides when and where to go based on events from ViewModel (using RxSwift bindings). 31 | 32 | `View` <- `ViewController` <- bindings -> (`ViewModel` -> `Model`) <- bindings -> `Coordinator` 33 | 34 | 35 | ## Coordinators hierarchy 36 | ![](https://github.com/wojciech-kulik/Swift-MVVMC-Demo/blob/master/coordinators.png) 37 | 38 | ## Pros 39 | - View doesn't contain logic, it just configures itself based on ViewModel. 40 | - ViewModel is UIKit independent and fully testable. Thanks to communication through RxSwift it doesn't know about Coordinator nor about View. 41 | - Views and ViewModels are reusable because they are indepdent and doesn't contain knowledge about application's flow. 42 | - Coordinator is able to handle passing data between ViewModels. 43 | 44 | ## Cons 45 | - Each screen requires a lot of boilerplate. You need to create Coordinator, ViewController, ViewModel and bind all together. 46 | - RxSwift is quite tricky if you are not careful enough. It's easy to cause memory leak, that's why you have to invest more time in debugging. 47 | - Bindings are not supported natively (unlike in Xamarin.Forms), therefore it is required to write a lot of "binding code" each time even when using RxSwift. 48 | - RxSwift may become hard in debugging once code complexity increases. 49 | 50 | ## Compilation 51 | Project uses [CocoaPods](https://cocoapods.org) for dependencies, so install it first and then run: 52 | 53 | pod install 54 | 55 | ## Application usage 56 | Sample login screen accepts any e-mail address and password `pass`. 57 | 58 | ## References 59 | - [MVVM + Coordinators + RxSwift based on sample iOS application with authentication](https://wojciechkulik.pl/ios/mvvm-coordinators-rxswift-and-sample-ios-application-with-authentication) 60 | - [How to use MVVM, Coordinators, and RxSwift](https://hackernoon.com/how-to-use-mvvm-coordinators-and-rxswift-7364370b7b95) 61 | - [Simplified MVVM-C demo](https://github.com/wojciech-kulik/Swift-MVVMC-SimpleExample) 62 | 63 | You can also check out my another demo with Redux architecture: 64 | - https://github.com/wojciech-kulik/ReSwiftDemo 65 | -------------------------------------------------------------------------------- /coordinators.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/coordinators.png -------------------------------------------------------------------------------- /screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wojciech-kulik/Swift-MVVMC-Demo/3e78334b162fabf3c107372fa8e72c6b28022a28/screenshots.png --------------------------------------------------------------------------------