From 56673d7061a3c237bdbd667c42ae50d2bd37b5ae Mon Sep 17 00:00:00 2001 From: DaoLQ Date: Thu, 12 Apr 2018 15:07:39 +0700 Subject: [PATCH] MVVM Architect --- src/Podfile | 7 +- src/Structure_IOS.xcodeproj/project.pbxproj | 83 ++++++++++++++-- src/Structure_IOS/Common/ViewModelType.swift | 16 ++++ .../Source/Remote/API/Error/BaseError.swift | 1 + .../Remote/API/Middleware/BaseResult.swift | 15 --- .../API/Middleware/CustomRequestAdapter.swift | 9 +- .../API/Request/BaseUploadRequest.swift | 31 ++++++ .../Source/Remote/Service/APIService.swift | 95 ++++++++++++++----- .../Extensions/ObservableExtension.swift | 65 +++++++++++++ .../UIViewControllerExtendsion.swift | 2 +- .../Repositories/UserRepository.swift | 24 ++--- .../Scenes/Base.lproj/Main.storyboard | 18 +++- .../{ => Cell}/ListUsersTableViewCell.swift | 5 + .../{ => Cell}/ListUsersTableViewCell.xib | 0 .../ListUser/Cell/UserItemViewModel.swift | 19 ++++ .../Scenes/ListUser/ListUserViewModel.swift | 42 ++++++++ .../ListUser/ListUsersViewController.swift | 69 ++++++++------ .../Scenes/Search/SearchNavigator.swift | 28 ++++++ .../Scenes/Search/SearchViewController.swift | 60 +++++++----- .../Scenes/Search/SearchViewModel.swift | 83 ++++++++++++++++ src/Structure_IOS/Utils/Constants.swift | 2 + .../Utils/Reactive/ActivityIndicator.swift | 57 +++++++++++ .../Utils/Reactive/ErrorTracker.swift | 42 ++++++++ 23 files changed, 650 insertions(+), 123 deletions(-) create mode 100644 src/Structure_IOS/Common/ViewModelType.swift delete mode 100644 src/Structure_IOS/Data/Source/Remote/API/Middleware/BaseResult.swift create mode 100644 src/Structure_IOS/Data/Source/Remote/API/Request/BaseUploadRequest.swift create mode 100644 src/Structure_IOS/Extensions/ObservableExtension.swift rename src/Structure_IOS/Scenes/ListUser/{ => Cell}/ListUsersTableViewCell.swift (68%) rename src/Structure_IOS/Scenes/ListUser/{ => Cell}/ListUsersTableViewCell.xib (100%) create mode 100644 src/Structure_IOS/Scenes/ListUser/Cell/UserItemViewModel.swift create mode 100644 src/Structure_IOS/Scenes/ListUser/ListUserViewModel.swift create mode 100644 src/Structure_IOS/Scenes/Search/SearchNavigator.swift create mode 100644 src/Structure_IOS/Scenes/Search/SearchViewModel.swift create mode 100644 src/Structure_IOS/Utils/Reactive/ActivityIndicator.swift create mode 100644 src/Structure_IOS/Utils/Reactive/ErrorTracker.swift diff --git a/src/Podfile b/src/Podfile index de1ebc7..92d18ff 100644 --- a/src/Podfile +++ b/src/Podfile @@ -3,7 +3,10 @@ use_frameworks! platform :ios, '9.0' target 'Structure_IOS' do - pod 'Alamofire', '~> 4.5' - pod 'AlamofireObjectMapper', '~> 4.1.0' + pod 'Alamofire', '~> 4.7.1' + pod 'AlamofireObjectMapper', '~> 5.0.0' pod 'SwiftLint' + pod 'RxSwift', '~> 4.1.2' + pod 'RxCocoa', '~> 4.1.2' + pod 'SVProgressHUD', '~> 2.2.5' end diff --git a/src/Structure_IOS.xcodeproj/project.pbxproj b/src/Structure_IOS.xcodeproj/project.pbxproj index 9a25b30..1f94d46 100644 --- a/src/Structure_IOS.xcodeproj/project.pbxproj +++ b/src/Structure_IOS.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 7EA1CB01F340C2EDF20BE29F /* Pods_Structure_IOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7D20A3FE6772D45D22CEB40A /* Pods_Structure_IOS.framework */; }; + BF0A01C6207B408C002AD1DF /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0A01C5207B408C002AD1DF /* SearchViewModel.swift */; }; + BF0A01CD207B6FF8002AD1DF /* SearchNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0A01CC207B6FF8002AD1DF /* SearchNavigator.swift */; }; + BF0A01D0207C54C9002AD1DF /* ViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0A01CF207C54C9002AD1DF /* ViewModelType.swift */; }; BF3427251F9EE3D80083B79E /* BaseUIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3427241F9EE3D80083B79E /* BaseUIViewController.swift */; }; BF34272E1F9EF1EE0083B79E /* ListUsersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF34272C1F9EF1EE0083B79E /* ListUsersViewController.swift */; }; BF34272F1F9EF1EE0083B79E /* ListUsersViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = BF34272D1F9EF1EE0083B79E /* ListUsersViewController.xib */; }; @@ -17,8 +20,10 @@ BF34273C1F9F387D0083B79E /* AlertViewControllerExtendsion.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF34273B1F9F387D0083B79E /* AlertViewControllerExtendsion.swift */; }; BF48AFB8207B2098002764A1 /* LocalDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF48AFB7207B2098002764A1 /* LocalDatabase.swift */; }; BF48AFBC207B20D4002764A1 /* CustomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF48AFBB207B20D4002764A1 /* CustomView.swift */; }; + BF50E14C207DBAF00057294C /* ErrorTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF50E14B207DBAF00057294C /* ErrorTracker.swift */; }; + BF50E14E207DBCE80057294C /* ObservableExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF50E14D207DBCE80057294C /* ObservableExtension.swift */; }; + BF5DF85D2081122F00D52EC7 /* BaseUploadRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF5DF85C2081122F00D52EC7 /* BaseUploadRequest.swift */; }; BF6BD7D31F9D9A1B005AE359 /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6BD7D21F9D9A1B005AE359 /* UserRepository.swift */; }; - BF6BD7D51F9DA24E005AE359 /* BaseResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6BD7D41F9DA24E005AE359 /* BaseResult.swift */; }; BFACA53F1F9B2499003EB555 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFACA53E1F9B2499003EB555 /* AppDelegate.swift */; }; BFACA5411F9B2499003EB555 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFACA5401F9B2499003EB555 /* SearchViewController.swift */; }; BFACA5441F9B2499003EB555 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = BFACA5421F9B2499003EB555 /* Main.storyboard */; }; @@ -34,12 +39,18 @@ BFACA5781F9CEDBE003EB555 /* BaseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFACA5771F9CEDBE003EB555 /* BaseModel.swift */; }; BFBF4CFE1F9DC3CA00F1E48B /* BaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBF4CFD1F9DC3CA00F1E48B /* BaseError.swift */; }; BFBF4D011F9DD9A400F1E48B /* URLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFBF4D001F9DD9A400F1E48B /* URLs.swift */; }; + BFC08F54207F1AD200B79266 /* ActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC08F53207F1AD200B79266 /* ActivityIndicator.swift */; }; + BFC08F56207F568F00B79266 /* ListUserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC08F55207F568F00B79266 /* ListUserViewModel.swift */; }; + BFC08F58207F5CE900B79266 /* UserItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC08F57207F5CE900B79266 /* UserItemViewModel.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 129EA84272B1A0610498CC0B /* Pods-Structure_IOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Structure_IOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-Structure_IOS/Pods-Structure_IOS.release.xcconfig"; sourceTree = ""; }; 71E2255F413B57587C762BDB /* Pods-Structure_IOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Structure_IOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Structure_IOS/Pods-Structure_IOS.debug.xcconfig"; sourceTree = ""; }; 7D20A3FE6772D45D22CEB40A /* Pods_Structure_IOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Structure_IOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BF0A01C5207B408C002AD1DF /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; + BF0A01CC207B6FF8002AD1DF /* SearchNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchNavigator.swift; sourceTree = ""; }; + BF0A01CF207C54C9002AD1DF /* ViewModelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModelType.swift; sourceTree = ""; }; BF3427241F9EE3D80083B79E /* BaseUIViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseUIViewController.swift; sourceTree = ""; }; BF34272C1F9EF1EE0083B79E /* ListUsersViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListUsersViewController.swift; sourceTree = ""; }; BF34272D1F9EF1EE0083B79E /* ListUsersViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ListUsersViewController.xib; sourceTree = ""; }; @@ -49,8 +60,10 @@ BF34273B1F9F387D0083B79E /* AlertViewControllerExtendsion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertViewControllerExtendsion.swift; sourceTree = ""; }; BF48AFB7207B2098002764A1 /* LocalDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalDatabase.swift; sourceTree = ""; }; BF48AFBB207B20D4002764A1 /* CustomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomView.swift; sourceTree = ""; }; + BF50E14B207DBAF00057294C /* ErrorTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTracker.swift; sourceTree = ""; }; + BF50E14D207DBCE80057294C /* ObservableExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableExtension.swift; sourceTree = ""; }; + BF5DF85C2081122F00D52EC7 /* BaseUploadRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseUploadRequest.swift; sourceTree = ""; }; BF6BD7D21F9D9A1B005AE359 /* UserRepository.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = ""; }; - BF6BD7D41F9DA24E005AE359 /* BaseResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseResult.swift; sourceTree = ""; }; BFACA53B1F9B2499003EB555 /* Structure_IOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Structure_IOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; BFACA53E1F9B2499003EB555 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; BFACA5401F9B2499003EB555 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; @@ -68,6 +81,9 @@ BFACA5771F9CEDBE003EB555 /* BaseModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseModel.swift; sourceTree = ""; }; BFBF4CFD1F9DC3CA00F1E48B /* BaseError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseError.swift; sourceTree = ""; }; BFBF4D001F9DD9A400F1E48B /* URLs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLs.swift; sourceTree = ""; }; + BFC08F53207F1AD200B79266 /* ActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicator.swift; sourceTree = ""; }; + BFC08F55207F568F00B79266 /* ListUserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListUserViewModel.swift; sourceTree = ""; }; + BFC08F57207F5CE900B79266 /* UserItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserItemViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -90,6 +106,14 @@ name = Frameworks; sourceTree = ""; }; + BF0A01CE207C54B8002AD1DF /* Common */ = { + isa = PBXGroup; + children = ( + BF0A01CF207C54C9002AD1DF /* ViewModelType.swift */, + ); + path = Common; + sourceTree = ""; + }; BF48AFB9207B20AD002764A1 /* Widgets */ = { isa = PBXGroup; children = ( @@ -119,10 +143,11 @@ BFACA53D1F9B2499003EB555 /* Structure_IOS */ = { isa = PBXGroup; children = ( + BFACA54A1F9B2499003EB555 /* Info.plist */, + BF0A01CE207C54B8002AD1DF /* Common */, BFF6907C207B04BD005BFE83 /* Application */, BFF6907E207B051A005BFE83 /* Data */, BFF6908B207B0942005BFE83 /* Extensions */, - BFACA54A1F9B2499003EB555 /* Info.plist */, BFF69092207B0AEA005BFE83 /* Repositories */, BFF6908D207B0A2A005BFE83 /* Resources */, BFF6908F207B0A68005BFE83 /* Scenes */, @@ -132,6 +157,25 @@ path = Structure_IOS; sourceTree = ""; }; + BFC08F52207F1AB700B79266 /* Reactive */ = { + isa = PBXGroup; + children = ( + BF50E14B207DBAF00057294C /* ErrorTracker.swift */, + BFC08F53207F1AD200B79266 /* ActivityIndicator.swift */, + ); + path = Reactive; + sourceTree = ""; + }; + BFC08F59207FACA900B79266 /* Cell */ = { + isa = PBXGroup; + children = ( + BFC08F57207F5CE900B79266 /* UserItemViewModel.swift */, + BF3427321F9EF3A20083B79E /* ListUsersTableViewCell.swift */, + BF3427331F9EF3A20083B79E /* ListUsersTableViewCell.xib */, + ); + path = Cell; + sourceTree = ""; + }; BFF6907C207B04BD005BFE83 /* Application */ = { isa = PBXGroup; children = ( @@ -180,6 +224,7 @@ children = ( BFACA5661F9BA8E5003EB555 /* SearchRequest.swift */, BFACA5681F9BA934003EB555 /* BaseRequest.swift */, + BF5DF85C2081122F00D52EC7 /* BaseUploadRequest.swift */, ); path = Request; sourceTree = ""; @@ -207,7 +252,6 @@ BFF69086207B07F5005BFE83 /* Middleware */ = { isa = PBXGroup; children = ( - BF6BD7D41F9DA24E005AE359 /* BaseResult.swift */, BFACA56C1F9C7538003EB555 /* CustomRequestAdapter.swift */, ); path = Middleware; @@ -243,6 +287,7 @@ children = ( BF34273B1F9F387D0083B79E /* AlertViewControllerExtendsion.swift */, BF3427391F9F37C90083B79E /* UIViewControllerExtendsion.swift */, + BF50E14D207DBCE80057294C /* ObservableExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -269,10 +314,10 @@ BFF69090207B0A77005BFE83 /* ListUser */ = { isa = PBXGroup; children = ( - BF3427331F9EF3A20083B79E /* ListUsersTableViewCell.xib */, + BFC08F59207FACA900B79266 /* Cell */, BF34272D1F9EF1EE0083B79E /* ListUsersViewController.xib */, BF34272C1F9EF1EE0083B79E /* ListUsersViewController.swift */, - BF3427321F9EF3A20083B79E /* ListUsersTableViewCell.swift */, + BFC08F55207F568F00B79266 /* ListUserViewModel.swift */, ); path = ListUser; sourceTree = ""; @@ -281,6 +326,8 @@ isa = PBXGroup; children = ( BFACA5401F9B2499003EB555 /* SearchViewController.swift */, + BF0A01C5207B408C002AD1DF /* SearchViewModel.swift */, + BF0A01CC207B6FF8002AD1DF /* SearchNavigator.swift */, ); path = Search; sourceTree = ""; @@ -296,6 +343,7 @@ BFF69093207B0B22005BFE83 /* Utils */ = { isa = PBXGroup; children = ( + BFC08F52207F1AB700B79266 /* Reactive */, BFBF4D001F9DD9A400F1E48B /* URLs.swift */, BFACA5601F9B4CE1003EB555 /* Constants.swift */, ); @@ -347,6 +395,7 @@ BFACA53A1F9B2499003EB555 = { CreatedOnToolsVersion = 8.3.3; DevelopmentTeam = QRJNQQU64D; + LastSwiftMigration = 0920; ProvisioningStyle = Automatic; }; }; @@ -394,12 +443,18 @@ "${BUILT_PRODUCTS_DIR}/Alamofire/Alamofire.framework", "${BUILT_PRODUCTS_DIR}/AlamofireObjectMapper/AlamofireObjectMapper.framework", "${BUILT_PRODUCTS_DIR}/ObjectMapper/ObjectMapper.framework", + "${BUILT_PRODUCTS_DIR}/RxCocoa/RxCocoa.framework", + "${BUILT_PRODUCTS_DIR}/RxSwift/RxSwift.framework", + "${BUILT_PRODUCTS_DIR}/SVProgressHUD/SVProgressHUD.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Alamofire.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/AlamofireObjectMapper.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ObjectMapper.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxCocoa.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxSwift.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SVProgressHUD.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; @@ -451,23 +506,31 @@ BFACA5411F9B2499003EB555 /* SearchViewController.swift in Sources */, BFACA5781F9CEDBE003EB555 /* BaseModel.swift in Sources */, BFACA5671F9BA8E5003EB555 /* SearchRequest.swift in Sources */, + BF50E14C207DBAF00057294C /* ErrorTracker.swift in Sources */, BFBF4CFE1F9DC3CA00F1E48B /* BaseError.swift in Sources */, + BF50E14E207DBCE80057294C /* ObservableExtension.swift in Sources */, BFACA55F1F9B3FB9003EB555 /* APIService.swift in Sources */, BFACA53F1F9B2499003EB555 /* AppDelegate.swift in Sources */, BFACA5691F9BA934003EB555 /* BaseRequest.swift in Sources */, BF48AFB8207B2098002764A1 /* LocalDatabase.swift in Sources */, + BFC08F58207F5CE900B79266 /* UserItemViewModel.swift in Sources */, BF34272E1F9EF1EE0083B79E /* ListUsersViewController.swift in Sources */, BFACA5731F9C7E36003EB555 /* SearchResponse.swift in Sources */, BFACA5711F9C7B5B003EB555 /* ErrorResponse.swift in Sources */, BF3427341F9EF3A20083B79E /* ListUsersTableViewCell.swift in Sources */, + BFC08F54207F1AD200B79266 /* ActivityIndicator.swift in Sources */, + BFC08F56207F568F00B79266 /* ListUserViewModel.swift in Sources */, + BF0A01C6207B408C002AD1DF /* SearchViewModel.swift in Sources */, BFBF4D011F9DD9A400F1E48B /* URLs.swift in Sources */, BF34273A1F9F37C90083B79E /* UIViewControllerExtendsion.swift in Sources */, - BF6BD7D51F9DA24E005AE359 /* BaseResult.swift in Sources */, + BF0A01CD207B6FF8002AD1DF /* SearchNavigator.swift in Sources */, + BF0A01D0207C54C9002AD1DF /* ViewModelType.swift in Sources */, BF48AFBC207B20D4002764A1 /* CustomView.swift in Sources */, BF6BD7D31F9D9A1B005AE359 /* UserRepository.swift in Sources */, BF3427251F9EE3D80083B79E /* BaseUIViewController.swift in Sources */, BFACA56D1F9C7538003EB555 /* CustomRequestAdapter.swift in Sources */, BFACA5611F9B4CE1003EB555 /* Constants.swift in Sources */, + BF5DF85D2081122F00D52EC7 /* BaseUploadRequest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -588,7 +651,8 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.fstyle.Structure-IOS"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 3.0; + SWIFT_SWIFT3_OBJC_INFERENCE = On; + SWIFT_VERSION = 4.0; }; name = Debug; }; @@ -602,7 +666,8 @@ LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "com.fstyle.Structure-IOS"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 3.0; + SWIFT_SWIFT3_OBJC_INFERENCE = On; + SWIFT_VERSION = 4.0; }; name = Release; }; diff --git a/src/Structure_IOS/Common/ViewModelType.swift b/src/Structure_IOS/Common/ViewModelType.swift new file mode 100644 index 0000000..f2cfc0d --- /dev/null +++ b/src/Structure_IOS/Common/ViewModelType.swift @@ -0,0 +1,16 @@ +// +// ViewModelType.swift +// Structure_IOS +// +// Created by DaoLQ on 4/10/18. +// Copyright © 2018 DaoLQ. All rights reserved. +// + +import Foundation + +protocol ViewModelType { + associatedtype Input + associatedtype Output + + func transform(input: Input) -> Output +} diff --git a/src/Structure_IOS/Data/Source/Remote/API/Error/BaseError.swift b/src/Structure_IOS/Data/Source/Remote/API/Error/BaseError.swift index cdfa141..89d1ac9 100644 --- a/src/Structure_IOS/Data/Source/Remote/API/Error/BaseError.swift +++ b/src/Structure_IOS/Data/Source/Remote/API/Error/BaseError.swift @@ -9,6 +9,7 @@ import Foundation enum BaseError: Error { + case networkError case httpError(httpCode: Int) case unexpectedError diff --git a/src/Structure_IOS/Data/Source/Remote/API/Middleware/BaseResult.swift b/src/Structure_IOS/Data/Source/Remote/API/Middleware/BaseResult.swift deleted file mode 100644 index cc15676..0000000 --- a/src/Structure_IOS/Data/Source/Remote/API/Middleware/BaseResult.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// BaseResult.swift -// Structure_IOS -// -// Created by DaoLQ on 10/23/17. -// Copyright © 2017 DaoLQ. All rights reserved. -// - -import Foundation -import ObjectMapper - -enum BaseResult { - case success(T?) - case failure(error: BaseError?) -} diff --git a/src/Structure_IOS/Data/Source/Remote/API/Middleware/CustomRequestAdapter.swift b/src/Structure_IOS/Data/Source/Remote/API/Middleware/CustomRequestAdapter.swift index 78834b9..16465cf 100644 --- a/src/Structure_IOS/Data/Source/Remote/API/Middleware/CustomRequestAdapter.swift +++ b/src/Structure_IOS/Data/Source/Remote/API/Middleware/CustomRequestAdapter.swift @@ -9,11 +9,14 @@ import Foundation import Alamofire -class CustomRequestAdapter: RequestAdapter { +final class CustomRequestAdapter: RequestAdapter { + private let userDefault = UserDefaults() + func adapt(_ urlRequest: URLRequest) throws -> URLRequest { var urlRequest = urlRequest - -// urlRequest.setValue(MY_API_KEY, forHTTPHeaderField: "X-AccessToken") + if let accessToken = userDefault.string(forKey: Constants.keyAccessToken) { + urlRequest.setValue(accessToken, forHTTPHeaderField: "X-AccessToken") + } urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") return urlRequest } diff --git a/src/Structure_IOS/Data/Source/Remote/API/Request/BaseUploadRequest.swift b/src/Structure_IOS/Data/Source/Remote/API/Request/BaseUploadRequest.swift new file mode 100644 index 0000000..c572d92 --- /dev/null +++ b/src/Structure_IOS/Data/Source/Remote/API/Request/BaseUploadRequest.swift @@ -0,0 +1,31 @@ +// +// BaseUploadRequest.swift +// Structure_IOS +// +// Created by DaoLQ on 4/13/18. +// Copyright © 2018 DaoLQ. All rights reserved. +// + +import Foundation +import ObjectMapper + +class BaseUploadRequest: NSObject { + + var url = "" + var parameters: [String: Any]? + var files: [File]? + + init(url: String) { + self.url = url + } + + init(url: String, files: [File]) { + self.url = url + self.files = files + } +} + +struct File { + var key: String + var path: String +} diff --git a/src/Structure_IOS/Data/Source/Remote/Service/APIService.swift b/src/Structure_IOS/Data/Source/Remote/Service/APIService.swift index ce87598..7d5897c 100644 --- a/src/Structure_IOS/Data/Source/Remote/Service/APIService.swift +++ b/src/Structure_IOS/Data/Source/Remote/Service/APIService.swift @@ -9,6 +9,7 @@ import UIKit import Alamofire import ObjectMapper +import RxSwift struct APIService { @@ -24,39 +25,85 @@ struct APIService { alamofireManager.adapter = CustomRequestAdapter() } - func request(input: BaseRequest, completion: @escaping (_ value: T?,_ error: BaseError?) -> Void) { - + func request(input: BaseRequest) -> Observable { + print("\n------------REQUEST INPUT") print("link: %@", input.url) print("body: %@", input.body ?? "No Body") print("------------ END REQUEST INPUT\n") - - alamofireManager.request(input.url, method: input.requestType, parameters: input.body, encoding: input.encoding) - .validate(statusCode: 200..<500) - .responseJSON { response in - print(response.request?.url ?? "Error") - print(response) - switch response.result { - case .success(let value): - if let statusCode = response.response?.statusCode { - if statusCode == 200 { - let object = Mapper().map(JSONObject: value) - completion(object, nil) - } else { - if let error = Mapper().map(JSONObject: value) { - completion(nil, BaseError.apiFailure(error: error)) + + return Observable.create { observer in + self.alamofireManager.request(input.url, method: input.requestType, + parameters: input.body, encoding: input.encoding) + .validate(statusCode: 200..<500) + .responseJSON { response in + print(response.request?.url ?? "Error") + print(response) + switch response.result { + case .success(let value): + if let statusCode = response.response?.statusCode { + if statusCode == 200 { + if let object = Mapper().map(JSONObject: value) { + observer.onNext(object) + } } else { - completion(nil, BaseError.httpError(httpCode: statusCode)) + if let object = Mapper().map(JSONObject: value) { + observer.onError(BaseError.apiFailure(error: object)) + } else { + observer.onError(BaseError.httpError(httpCode: statusCode)) + } } + } else { + observer.on(.error(BaseError.unexpectedError)) + } + observer.onCompleted() + case .failure: + observer.onError(BaseError.networkError) + observer.onCompleted() + } + } + return Disposables.create() + } + } + + func upload(input: BaseUploadRequest) -> Observable { + + print("\n------------ UPLOAD INPUT") + print("link: %@", input.url) + print("body: %@", input.parameters ?? "No Body") + print("------------ END UPLOAD INPUT\n") + + return Observable.create({ observer in + self.alamofireManager.upload(multipartFormData: { multipartFormData in + if let parameters = input.parameters { + for (key, value) in parameters { + multipartFormData.append("\(value)".data(using: String.Encoding.utf8)!, withName: key) + } + } + if let files = input.files { + files.forEach { file in + if let url = URL(string: file.path) { + multipartFormData.append(url, withName: file.key) } - } else { - completion(nil, BaseError.unexpectedError) } - break + } + }, usingThreshold: UInt64.init(), to: input.url) { result in + switch result { + case .success(let upload, _, _): + upload.responseJSON { response in + if let error = response.error { + observer.onError(error) + return + } + observer.onNext() + observer.onCompleted() + } case .failure(let error): - completion(nil, error as? BaseError) - break + observer.onError(error) + observer.onCompleted() } - } + } + return Disposables.create() + }) } } diff --git a/src/Structure_IOS/Extensions/ObservableExtension.swift b/src/Structure_IOS/Extensions/ObservableExtension.swift new file mode 100644 index 0000000..b6d1108 --- /dev/null +++ b/src/Structure_IOS/Extensions/ObservableExtension.swift @@ -0,0 +1,65 @@ +// +// ObservableExtension.swift +// Structure_IOS +// +// Created by DaoLQ on 4/11/18. +// Copyright © 2018 DaoLQ. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa +import SVProgressHUD + +extension ObservableType where E == Bool { + /// Boolean not operator + public func not() -> Observable { + return self.map(!) + } + +} + +extension SharedSequenceConvertibleType { + func mapToVoid() -> SharedSequence { + return map { _ in } + } +} + +extension ObservableType { + + func catchErrorJustComplete() -> Observable { + return catchError { _ in + return Observable.empty() + } + } + + func asDriverOnErrorJustComplete() -> Driver { + return asDriver { _ in + return Driver.empty() + } + } + + func mapToVoid() -> Observable { + return map { _ in } + } +} + +extension ObserverType where E == Void { + public func onNext() { + onNext(()) + } +} + +extension Reactive where Base: SVProgressHUD { + + /// Bindable sink for `show()`, `hide()` methods. + public static var isAnimating: Binder { + return Binder(UIApplication.shared) { _, isVisible in + if isVisible { + SVProgressHUD.show() + } else { + SVProgressHUD.dismiss() + } + } + } +} diff --git a/src/Structure_IOS/Extensions/UIViewControllerExtendsion.swift b/src/Structure_IOS/Extensions/UIViewControllerExtendsion.swift index 5690edb..87a39e0 100644 --- a/src/Structure_IOS/Extensions/UIViewControllerExtendsion.swift +++ b/src/Structure_IOS/Extensions/UIViewControllerExtendsion.swift @@ -17,7 +17,7 @@ extension UIViewController { view.addGestureRecognizer(tap) } - func dismissKeyboard() { + @objc func dismissKeyboard() { view.endEditing(true) } } diff --git a/src/Structure_IOS/Repositories/UserRepository.swift b/src/Structure_IOS/Repositories/UserRepository.swift index b6a01ed..ab67b59 100644 --- a/src/Structure_IOS/Repositories/UserRepository.swift +++ b/src/Structure_IOS/Repositories/UserRepository.swift @@ -8,30 +8,24 @@ import Foundation import ObjectMapper +import RxSwift protocol UserRepository { - func searchUsers(keyword: String, limit: Int, completion: @escaping (BaseResult) -> Void) + func searchUsers(input: SearchRequest) -> Observable<[User]> } class UserRepositoryImpl: UserRepository { - private var api: APIService? + private var api: APIService! required init(api: APIService) { self.api = api } - - func searchUsers(keyword: String, limit: Int, completion: @escaping (BaseResult) -> Void) { - let input = SearchRequest(keyword: keyword, limit: limit) - - api?.request(input: input) { (object: SearchResponse?, error) in - if let object = object { - completion(.success(object)) - } else if let error = error { - completion(.failure(error: error)) - } else { - completion(.failure(error: nil)) - } - } + + func searchUsers(input: SearchRequest) -> Observable<[User]> { + return api.request(input: input) + .map({ (response: SearchResponse) -> [User] in + return response.users + }) } } diff --git a/src/Structure_IOS/Scenes/Base.lproj/Main.storyboard b/src/Structure_IOS/Scenes/Base.lproj/Main.storyboard index a85e5ed..7b4ae4f 100644 --- a/src/Structure_IOS/Scenes/Base.lproj/Main.storyboard +++ b/src/Structure_IOS/Scenes/Base.lproj/Main.storyboard @@ -73,10 +73,16 @@ - - - + @@ -84,6 +90,7 @@ + @@ -91,6 +98,8 @@ + + @@ -98,8 +107,9 @@ + - + diff --git a/src/Structure_IOS/Scenes/ListUser/ListUsersTableViewCell.swift b/src/Structure_IOS/Scenes/ListUser/Cell/ListUsersTableViewCell.swift similarity index 68% rename from src/Structure_IOS/Scenes/ListUser/ListUsersTableViewCell.swift rename to src/Structure_IOS/Scenes/ListUser/Cell/ListUsersTableViewCell.swift index be5da37..1b380ea 100644 --- a/src/Structure_IOS/Scenes/ListUser/ListUsersTableViewCell.swift +++ b/src/Structure_IOS/Scenes/ListUser/Cell/ListUsersTableViewCell.swift @@ -9,9 +9,14 @@ import UIKit class ListUsersTableViewCell: UITableViewCell { + static let reuseID = "ListUsersTableViewCell" @IBOutlet weak var userNameLabel: UILabel! + func bind(viewModel: UserItemViewModel) { + self.userNameLabel.text = viewModel.login + } + func updateCell(user: User?) { userNameLabel.text = user?.login } diff --git a/src/Structure_IOS/Scenes/ListUser/ListUsersTableViewCell.xib b/src/Structure_IOS/Scenes/ListUser/Cell/ListUsersTableViewCell.xib similarity index 100% rename from src/Structure_IOS/Scenes/ListUser/ListUsersTableViewCell.xib rename to src/Structure_IOS/Scenes/ListUser/Cell/ListUsersTableViewCell.xib diff --git a/src/Structure_IOS/Scenes/ListUser/Cell/UserItemViewModel.swift b/src/Structure_IOS/Scenes/ListUser/Cell/UserItemViewModel.swift new file mode 100644 index 0000000..1f90285 --- /dev/null +++ b/src/Structure_IOS/Scenes/ListUser/Cell/UserItemViewModel.swift @@ -0,0 +1,19 @@ +// +// UserItemViewModel.swift +// Structure_IOS +// +// Created by DaoLQ on 4/12/18. +// Copyright © 2018 DaoLQ. All rights reserved. +// + +import Foundation + +final class UserItemViewModel { + let login: String? + let user: User + + init(with user: User) { + self.user = user + self.login = user.login + } +} diff --git a/src/Structure_IOS/Scenes/ListUser/ListUserViewModel.swift b/src/Structure_IOS/Scenes/ListUser/ListUserViewModel.swift new file mode 100644 index 0000000..996daa2 --- /dev/null +++ b/src/Structure_IOS/Scenes/ListUser/ListUserViewModel.swift @@ -0,0 +1,42 @@ +// +// ListUserViewModel.swift +// Structure_IOS +// +// Created by DaoLQ on 4/12/18. +// Copyright © 2018 DaoLQ. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +class ListUserViewModel: ViewModelType { + + var users: [User] + + init(users: [User]) { + self.users = users + } + + func transform(input: ListUserViewModel.Input) -> ListUserViewModel.Output { + + let userItemViewModels = Observable.just(self.users).asDriverOnErrorJustComplete() + .map { $0.map { UserItemViewModel(with: $0) }} + let selectedCell = input.selection.withLatestFrom(userItemViewModels) + { (indexPath, userItemViewModels) -> User in + return userItemViewModels[indexPath.row].user + } + return Output(users: userItemViewModels, selectedCell: selectedCell) + } +} + +extension ListUserViewModel { + struct Input { + let selection: Driver + } + + struct Output { + let users: Driver<[UserItemViewModel]> + let selectedCell: Driver + } +} diff --git a/src/Structure_IOS/Scenes/ListUser/ListUsersViewController.swift b/src/Structure_IOS/Scenes/ListUser/ListUsersViewController.swift index 364c81e..18ec8f8 100644 --- a/src/Structure_IOS/Scenes/ListUser/ListUsersViewController.swift +++ b/src/Structure_IOS/Scenes/ListUser/ListUsersViewController.swift @@ -7,49 +7,62 @@ // import UIKit +import RxSwift +import RxCocoa class ListUsersViewController: BaseUIViewController { + private let disposeBag = DisposeBag() + var viewModel: ListUserViewModel! - @IBOutlet weak var tableView: UITableView? + @IBOutlet weak var tableView: UITableView! var users: [User]? + static func createWith(viewModel: ListUserViewModel) -> ListUsersViewController { + let viewController = ListUsersViewController() + viewController.viewModel = viewModel + return viewController + } + override func viewDidLoad() { super.viewDidLoad() - self.navigationItem.title = "Search Result" - tableView?.delegate = self - tableView?.dataSource = self - tableView?.register(UINib(nibName: "ListUsersTableViewCell", bundle: nil), - forCellReuseIdentifier: "ListUsersTableViewCell") + configureTableView() + bindViewModel() } -} -extension ListUsersViewController: UITableViewDelegate, UITableViewDataSource { - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "ListUsersTableViewCell") - if let cell = cell as? ListUsersTableViewCell { - cell.updateCell(user: users?[indexPath.row]) - return cell - } - return UITableViewCell() + private func configureTableView() { + tableView.register(UINib(nibName: ListUsersTableViewCell.reuseID, bundle: nil), + forCellReuseIdentifier: ListUsersTableViewCell.reuseID) + tableView.estimatedRowHeight = 64 } - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if let size = users?.count { - return size - } - return 0 - } + private func bindViewModel() { + assert(viewModel != nil) + let input = ListUserViewModel.Input(selection: tableView.rx.itemSelected.asDriver()) - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return 75.0 - } + let output = viewModel.transform(input: input) + // MARK: Single Cell +// output.users.drive(tableView.rx.items(cellIdentifier: ListUsersTableViewCell.reuseID, +// cellType: ListUsersTableViewCell.self)) { (_, viewModel, cell) in +// cell.bind(viewModel: viewModel) +// }.disposed(by: disposeBag) + + // MARK: Multiple Cell + output.users.asObservable().bind(to: tableView.rx.items) { (tableView, row, element) in + let indexPath = IndexPath(row: row, section: 0) + var cell: UITableViewCell! + if indexPath.row == 1 { + cell = tableView.dequeueReusableCell(withIdentifier: ListUsersTableViewCell.reuseID, for: indexPath) + (cell as! ListUsersTableViewCell).bind(viewModel: element) + } else { + cell = tableView.dequeueReusableCell(withIdentifier: ListUsersTableViewCell.reuseID, for: indexPath) + (cell as! ListUsersTableViewCell).bind(viewModel: element) + } + return cell + }.disposed(by: disposeBag) - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let user = users?[indexPath.row] { - print(user) - } + output.selectedCell.drive().disposed(by: disposeBag) } } diff --git a/src/Structure_IOS/Scenes/Search/SearchNavigator.swift b/src/Structure_IOS/Scenes/Search/SearchNavigator.swift new file mode 100644 index 0000000..3ec2481 --- /dev/null +++ b/src/Structure_IOS/Scenes/Search/SearchNavigator.swift @@ -0,0 +1,28 @@ +// +// SearchNavigator.swift +// Structure_IOS +// +// Created by DaoLQ on 4/9/18. +// Copyright © 2018 DaoLQ. All rights reserved. +// + +import Foundation +import UIKit + +protocol SearchNavigator { + + func toListUser(users: [User]) +} + +final class DefaultSearchNavigator: SearchNavigator { + private let navigationController: UINavigationController? + + init(navigationController: UINavigationController?) { + self.navigationController = navigationController + } + + func toListUser(users: [User]) { + let listUserViewController = ListUsersViewController.createWith(viewModel: ListUserViewModel(users: users)) + navigationController?.pushViewController(listUserViewController, animated: false) + } +} diff --git a/src/Structure_IOS/Scenes/Search/SearchViewController.swift b/src/Structure_IOS/Scenes/Search/SearchViewController.swift index 50e383d..8274776 100644 --- a/src/Structure_IOS/Scenes/Search/SearchViewController.swift +++ b/src/Structure_IOS/Scenes/Search/SearchViewController.swift @@ -7,37 +7,53 @@ // import UIKit - +import RxSwift +import RxCocoa +import SVProgressHUD class SearchViewController: BaseUIViewController, AlertViewController { + private let disposeBag = DisposeBag() + var viewModel: SearchViewModel! - @IBOutlet weak var searchTextField: UITextField? - @IBOutlet weak var limitNumberTextField: UITextField? - @IBOutlet weak var searchButton: UIButton? - + @IBOutlet weak var searchTextField: UITextField! + @IBOutlet weak var limitNumberTextField: UITextField! + @IBOutlet weak var searchButton: UIButton! + @IBOutlet weak var errorLabel: UILabel! + private let userRepository: UserRepository = UserRepositoryImpl(api: APIService.share) override func viewDidLoad() { super.viewDidLoad() + + let userRepository = UserRepositoryImpl(api: APIService.share) + let navigator = DefaultSearchNavigator(navigationController: self.navigationController) + viewModel = SearchViewModel(userRepository: userRepository, navigator: navigator) + + let input = SearchViewModel.Input(keyword: searchTextField.rx.text.orEmpty.asDriver(), + limitNumber: limitNumberTextField.rx.text.orEmpty.asDriver(), + searchTrigger: searchButton.rx.tap.asDriver()) + + let output = viewModel.transform(input: input) + output.searchButtonEnable.drive(searchButton.rx.isEnabled) + .disposed(by: disposeBag) + output.errorInputNumber.drive(errorInputNumberBinding).disposed(by: disposeBag) + output.error.drive(errorBinding).disposed(by: disposeBag) + output.search.drive().disposed(by: disposeBag) + output.fetching.drive(SVProgressHUD.rx.isAnimating).disposed(by: disposeBag) + } + + var errorInputNumberBinding: Binder { + return Binder(self, binding: { (viewController, errorText) in + viewController.errorLabel.text = errorText + }) } - @IBAction func searchButtonClicked(_ sender: Any) { - let keyword = searchTextField?.text - let limit = limitNumberTextField?.text - if let keyword = keyword, let limit = limit { - if let limit = Int(limit) { - userRepository.searchUsers(keyword: keyword, limit: limit) { (result) in - switch result { - case .success(let searchResponse): - let listUserVC = ListUsersViewController() - listUserVC.users = searchResponse?.users - self.navigationController?.pushViewController(listUserVC, animated: true) - case .failure(let error): - self.showErrorAlert(message: error?.errorMessage) - } - } + var errorBinding: Binder { + return Binder(self, binding: { (viewController, error) in + guard let error = error as? BaseError else { + return } - } + viewController.showErrorAlert(message: error.errorMessage) + }) } - } diff --git a/src/Structure_IOS/Scenes/Search/SearchViewModel.swift b/src/Structure_IOS/Scenes/Search/SearchViewModel.swift new file mode 100644 index 0000000..0d1a001 --- /dev/null +++ b/src/Structure_IOS/Scenes/Search/SearchViewModel.swift @@ -0,0 +1,83 @@ +// +// SearchViewModel.swift +// Structure_IOS +// +// Created by DaoLQ on 4/9/18. +// Copyright © 2018 DaoLQ. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +final class SearchViewModel: ViewModelType { + private let userRepository: UserRepository + private let navigator: SearchNavigator + + init(userRepository: UserRepository, navigator: SearchNavigator) { + self.userRepository = userRepository + self.navigator = navigator + } + + func transform(input: SearchViewModel.Input) -> SearchViewModel.Output { + let errorTracker = ErrorTracker() + + let keywordAndLimitNumber = Driver.combineLatest(input.keyword, input.limitNumber) { (keyword, number) in + return (keyword: keyword, number: number) + } + let activityIndicator = ActivityIndicator() + + let canSearch = Driver.combineLatest(keywordAndLimitNumber, activityIndicator.asDriver()) + { (keywordAndLimitNumber, activityIndicator) -> Bool in + let limit = Int(keywordAndLimitNumber.number) + guard let number = limit else { + return false + } + return !keywordAndLimitNumber.keyword.isEmpty && number <= 100 && !activityIndicator + } + + let errorInputNumber = input.limitNumber.map { (input) -> Int? in + return Int(input) + }.map { (number) -> String in + if let number = number { + return number > 100 ? "Input number less than or equals 100" : "" + } + return "" + } + + let search = input.searchTrigger.withLatestFrom(keywordAndLimitNumber) + .map({ (keywordAndLimitNumber) -> SearchRequest in + let number = Int(keywordAndLimitNumber.number) + return SearchRequest(keyword: keywordAndLimitNumber.keyword, limit: number == nil ? 0: number!) + }) + .flatMapLatest { [unowned self] inputRequest in + return self.userRepository.searchUsers(input: inputRequest) + .trackActivity(activityIndicator) + .trackError(errorTracker) + .asDriverOnErrorJustComplete() + }.do(onNext: { [weak self] (users) in + self?.navigator.toListUser(users: users) + }).mapToVoid() + + return SearchViewModel.Output(searchButtonEnable: canSearch, errorInputNumber: errorInputNumber, + search: search, error: errorTracker.asDriver(), fetching: activityIndicator.asDriver()) + } + +} + +extension SearchViewModel { + struct Input { + let keyword: Driver + let limitNumber: Driver + + let searchTrigger: Driver + } + + struct Output { + let searchButtonEnable: Driver + let errorInputNumber: Driver + let search: Driver + let error: Driver + let fetching: Driver + } +} diff --git a/src/Structure_IOS/Utils/Constants.swift b/src/Structure_IOS/Utils/Constants.swift index 29253a2..659ff6b 100644 --- a/src/Structure_IOS/Utils/Constants.swift +++ b/src/Structure_IOS/Utils/Constants.swift @@ -10,4 +10,6 @@ import Foundation class Constants { public static let appName = "Structure IOS" + + public static let keyAccessToken = "AccessToken" } diff --git a/src/Structure_IOS/Utils/Reactive/ActivityIndicator.swift b/src/Structure_IOS/Utils/Reactive/ActivityIndicator.swift new file mode 100644 index 0000000..fc67c56 --- /dev/null +++ b/src/Structure_IOS/Utils/Reactive/ActivityIndicator.swift @@ -0,0 +1,57 @@ +// +// ActivityIndicator.swift +// Structure_IOS +// +// Created by DaoLQ on 4/12/18. +// Copyright © 2018 DaoLQ. All rights reserved. +// +import Foundation +import RxSwift +import RxCocoa + +public class ActivityIndicator: SharedSequenceConvertibleType { + public typealias E = Bool + public typealias SharingStrategy = DriverSharingStrategy + + private let _lock = NSRecursiveLock() + private let _variable = Variable(false) + private let _loading: SharedSequence + + public init() { + _loading = _variable.asDriver() + .distinctUntilChanged() + } + + fileprivate func trackActivityOfObservable(_ source: O) -> Observable { + return source.asObservable() + .do(onNext: { _ in + self.sendStopLoading() + }, onError: { _ in + self.sendStopLoading() + }, onCompleted: { + self.sendStopLoading() + }, onSubscribe: subscribed) + } + + private func subscribed() { + _lock.lock() + _variable.value = true + _lock.unlock() + } + + private func sendStopLoading() { + _lock.lock() + _variable.value = false + _lock.unlock() + } + + public func asSharedSequence() -> SharedSequence { + return _loading + } +} + +extension ObservableConvertibleType { + public func trackActivity(_ activityIndicator: ActivityIndicator) -> Observable { + return activityIndicator.trackActivityOfObservable(self) + } +} diff --git a/src/Structure_IOS/Utils/Reactive/ErrorTracker.swift b/src/Structure_IOS/Utils/Reactive/ErrorTracker.swift new file mode 100644 index 0000000..55d0cd3 --- /dev/null +++ b/src/Structure_IOS/Utils/Reactive/ErrorTracker.swift @@ -0,0 +1,42 @@ +// +// ErrorTracker.swift +// Structure_IOS +// +// Created by DaoLQ on 4/11/18. +// Copyright © 2018 DaoLQ. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa + +final class ErrorTracker: SharedSequenceConvertibleType { + typealias SharingStrategy = DriverSharingStrategy + private let _subject = PublishSubject() + + func trackError(from source: O) -> Observable { + return source.asObservable().do(onError: onError) + } + + func asSharedSequence() -> SharedSequence { + return _subject.asObservable().asDriverOnErrorJustComplete() + } + + func asObservable() -> Observable { + return _subject.asObservable() + } + + private func onError(_ error: Error) { + _subject.onNext(error) + } + + deinit { + _subject.onCompleted() + } +} + +extension ObservableConvertibleType { + func trackError(_ errorTracker: ErrorTracker) -> Observable { + return errorTracker.trackError(from: self) + } +}