├── .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 |
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 |
--------------------------------------------------------------------------------
/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 | [](https://www.buymeacoffee.com/WojciechKulik)
2 |
3 | ## Swift-MVVMC-Demo
4 | Sample iOS application in Swift presenting usage of MVVM-C pattern.
5 |
6 | 
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 | 
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
--------------------------------------------------------------------------------