// // Copyright (c) 2018 Touch Instinct // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the Software), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. // import UIKit import RxSwift import RxCocoa import LeadKit /// Describes pin image public enum PinImageType { case entered case clear } /// Pass code operation type public enum PassCodeControllerType { case create case enter case change } /// Pass code operation state public enum PassCodeControllerState { case enter case repeatEnter case oldEnter case newEnter } /// Base view controller that operates with pass code open class BasePassCodeViewController: UIViewController, ConfigurableController { public let viewModel: BasePassCodeViewModel public init(viewModel: BasePassCodeViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - IBOutlets @IBOutlet private weak var titleLabel: UILabel? @IBOutlet private weak var errorLabel: UILabel? @IBOutlet private weak var dotStackView: UIStackView! public let disposeBag = DisposeBag() private var delayedErrorDescriptions: Disposable? private lazy var fakeTextField: UITextField = { let fakeTextField = UITextField() fakeTextField.isSecureTextEntry = true fakeTextField.keyboardType = .numberPad fakeTextField.isHidden = true fakeTextField.delegate = self self.view.addSubview(fakeTextField) return fakeTextField }() // MARK: - Life circle override open func viewDidLoad() { super.viewDidLoad() initialLoadView() initialDotNumberConfiguration() configureBackgroundNotifications() showBiometricsRequestIfNeeded() } override open func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) fakeTextField.becomeFirstResponder() } override open func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) fakeTextField.resignFirstResponder() } // MARK: - Private functions private func configureBackgroundNotifications() { guard viewModel.passCodeConfiguration.shouldResetWhenGoBackground else { return } NotificationCenter.default.rx.notification(UIApplication.willResignActiveNotification) .subscribe(onNext: { [weak self] _ in self?.resetUI() }) .disposed(by: disposeBag) } private func initialDotNumberConfiguration() { dotStackView.arrangedSubviews.forEach { dotStackView.removeArrangedSubview($0) } for _ in 0 ..< viewModel.passCodeConfiguration.passCodeLength { let dotImageView = UIImageView() dotImageView.translatesAutoresizingMaskIntoConstraints = false dotImageView.widthAnchor.constraint(equalTo: dotImageView.heightAnchor, multiplier: 1) dotImageView.contentMode = .scaleAspectFit dotStackView.addArrangedSubview(dotImageView) } resetDotsUI() } private func resetDotsUI() { fakeTextField.text = nil dotStackView.arrangedSubviews .compactMap { $0 as? UIImageView } .forEach { $0.image = self.imageFor(type: .clear) } } private func setState(_ state: PinImageType, at index: Int) { guard dotStackView.arrangedSubviews.count > index, let imageView = dotStackView.arrangedSubviews[index] as? UIImageView else { return } imageView.image = imageFor(type: state) } private func setStates(for passCodeText: String) { var statesArray: [PinImageType] = [] for characterIndex in 0.. UIImage { assertionFailure("You should override this method: imageFor(type: PinImageType)") return UIImage() } /// Override to change error description open func errorDescription(for error: PassCodeError) -> [PassCodeDelayedDescription] { assertionFailure("You should override this method: errorDescription(for error: PassCodeError)") return [] } /// Override to change action title text open func actionTitle(for passCodeControllerState: PassCodeControllerState) -> NSAttributedString { assertionFailure("You should override this method: actionTitle(for passCodeControllerState: PassCodeControllerState)") return NSAttributedString(string: "") } // MARK: - Functions that you can override to customize your controller /// Call to show error open func showError(for error: PassCodeError) { let descriptionsObservables = errorDescription(for: error) .sorted { $0.delay < $1.delay } .map { [weak self] delayedDescription in Observable .interval(delayedDescription.delay, scheduler: MainScheduler.instance) .take(1) .do(onNext: { _ in self?.errorLabel?.attributedText = delayedDescription.description }) } delayedErrorDescriptions?.dispose() errorLabel?.attributedText = nil errorLabel?.isHidden = false delayedErrorDescriptions = Observable .merge(descriptionsObservables) .subscribe() } /// Call to disappear error label open func hideError() { errorLabel?.isHidden = true } /// Override to change UI for state open func configureUI(for passCodeControllerState: PassCodeControllerState) { resetDotsUI() titleLabel?.attributedText = actionTitle(for: passCodeControllerState) } // MARK: - ConfigurableController open func bindViews() { fakeTextField.rx.text.asDriver() .do(onNext: { [weak self] text in self?.setStates(for: text ?? "") self?.hideError() }) .delay(0.1) // time to draw dots .drive(onNext: { [weak self] text in self?.viewModel.setPassCodeText(text) }) .disposed(by: disposeBag) viewModel.validationResultDriver .drive(onNext: { [weak self] validationResult in guard let validationResult = validationResult else { return } if validationResult.isValid { self?.hideError() } else if let passCodeError = validationResult.error { self?.showError(for: passCodeError) } }) .disposed(by: disposeBag) viewModel.passCodeControllerStateDriver .drive(onNext: { [weak self] controllerState in self?.configureUI(for: controllerState) }) .disposed(by: disposeBag) } open func addViews() {} open func configureAppearance() {} open func configureBarButtons() {} open func localize() {} } // MARK: - UITextFieldDelegate extension BasePassCodeViewController: UITextFieldDelegate { public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { let invalid = CharacterSet(charactersIn: "0123456789").inverted return string.rangeOfCharacter(from: invalid, options: [], range: string.startIndex..