From 40a21a3a9fba8ddc8aeacd42e1f1b7f61e5d3d9e Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 22 Feb 2021 17:48:44 +0800 Subject: [PATCH] chore: make input responsible --- Mastodon/Generated/Assets.swift | 4 +- .../highlight.colorset/Contents.json | 20 + .../TextField/invalid.colorset/Contents.json | 20 + .../Contents.json | 0 .../MastodonRegisterViewController.swift | 130 ++-- .../MastodonRegisterViewController.swift.orig | 577 ++++++++++++++++++ .../Register/MastodonRegisterViewModel.swift | 118 ++-- 7 files changed, 759 insertions(+), 110 deletions(-) create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/TextField/highlight.colorset/Contents.json create mode 100644 Mastodon/Resources/Assets.xcassets/Colors/TextField/invalid.colorset/Contents.json rename Mastodon/Resources/Assets.xcassets/Colors/TextField/{successGreen.colorset => valid.colorset}/Contents.json (100%) create mode 100644 Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift.orig diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 4510a686..18f25f92 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -45,7 +45,9 @@ internal enum Asset { internal static let secondary = ColorAsset(name: "Colors/Label/secondary") } internal enum TextField { - internal static let successGreen = ColorAsset(name: "Colors/TextField/successGreen") + internal static let highlight = ColorAsset(name: "Colors/TextField/highlight") + internal static let invalid = ColorAsset(name: "Colors/TextField/invalid") + internal static let valid = ColorAsset(name: "Colors/TextField/valid") } internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow") internal static let lightBackground = ColorAsset(name: "Colors/lightBackground") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/TextField/highlight.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/TextField/highlight.colorset/Contents.json new file mode 100644 index 00000000..d853a71a --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/TextField/highlight.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "217", + "green" : "144", + "red" : "43" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/TextField/invalid.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/TextField/invalid.colorset/Contents.json new file mode 100644 index 00000000..dabccc33 --- /dev/null +++ b/Mastodon/Resources/Assets.xcassets/Colors/TextField/invalid.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.353", + "green" : "0.251", + "red" : "0.875" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Mastodon/Resources/Assets.xcassets/Colors/TextField/successGreen.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/TextField/successGreen.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/TextField/valid.colorset/Contents.json diff --git a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift index 8b95538b..98f876c3 100644 --- a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift @@ -210,7 +210,7 @@ extension MastodonRegisterViewController { // gesture view.addGestureRecognizer(tapGestureRecognizer) - tapGestureRecognizer.addTarget(self, action: #selector(_resignFirstResponder)) + tapGestureRecognizer.addTarget(self, action: #selector(tapGestureRecognizerHandler)) // stackview let stackView = UIStackView() @@ -354,45 +354,42 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) - viewModel.isUsernameValid + viewModel.usernameValidateState .receive(on: DispatchQueue.main) - .sink { [weak self] isValid in + .sink { [weak self] validateState in guard let self = self else { return } - self.setTextFieldValidAppearance(self.usernameTextField, isValid: isValid) + self.setTextFieldValidAppearance(self.usernameTextField, validateState: validateState) } .store(in: &disposeBag) - viewModel.isDisplaynameValid + viewModel.displayNameValidateState .receive(on: DispatchQueue.main) - .sink { [weak self] isValid in + .sink { [weak self] validateState in guard let self = self else { return } - self.setTextFieldValidAppearance(self.displayNameTextField, isValid: isValid) + self.setTextFieldValidAppearance(self.displayNameTextField, validateState: validateState) } .store(in: &disposeBag) - viewModel.isEmailValid + viewModel.emailValidateState .receive(on: DispatchQueue.main) - .sink { [weak self] isValid in + .sink { [weak self] validateState in guard let self = self else { return } - self.setTextFieldValidAppearance(self.emailTextField, isValid: isValid) + self.setTextFieldValidAppearance(self.emailTextField, validateState: validateState) } .store(in: &disposeBag) - viewModel.isPasswordValid + viewModel.passwordValidateState .receive(on: DispatchQueue.main) - .sink { [weak self] isValid in + .sink { [weak self] validateState in guard let self = self else { return } - self.setTextFieldValidAppearance(self.passwordTextField, isValid: isValid) + self.setTextFieldValidAppearance(self.passwordTextField, validateState: validateState) + self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validateState == .valid) + } .store(in: &disposeBag) - Publishers.CombineLatest4( - viewModel.isUsernameValid, - viewModel.isDisplaynameValid, - viewModel.isEmailValid, - viewModel.isPasswordValid - ) + viewModel.isAllValid .receive(on: DispatchQueue.main) - .sink { [weak self] isUsernameValid, isDisplaynameValid, isEmailValid, isPasswordValid in + .sink { [weak self] isAllValid in guard let self = self else { return } - self.signUpButton.isEnabled = isUsernameValid ?? false && isDisplaynameValid ?? false && isEmailValid ?? false && isPasswordValid ?? false + self.signUpButton.isEnabled = isAllValid } .store(in: &disposeBag) @@ -412,14 +409,39 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) + NotificationCenter.default + .publisher(for: UITextField.textDidChangeNotification, object: usernameTextField) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.username.value = self.usernameTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + .store(in: &disposeBag) + + NotificationCenter.default + .publisher(for: UITextField.textDidChangeNotification, object: displayNameTextField) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.displayName.value = self.displayNameTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + .store(in: &disposeBag) + + NotificationCenter.default + .publisher(for: UITextField.textDidChangeNotification, object: emailTextField) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.email.value = self.emailTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + .store(in: &disposeBag) + NotificationCenter.default .publisher(for: UITextField.textDidChangeNotification, object: passwordTextField) .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } - guard let text = self.passwordTextField.text else { return } - let validations = self.viewModel.validatePassword(text: text) - self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validations.0, oneNumber: validations.1, oneSpecialCharacter: validations.2) + self.viewModel.password.value = self.passwordTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } .store(in: &disposeBag) @@ -453,18 +475,21 @@ extension MastodonRegisterViewController: UITextFieldDelegate { // } // } // } - - func textFieldDidEndEditing(_ textField: UITextField) { + + func textFieldDidBeginEditing(_ textField: UITextField) { + let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + switch textField { case usernameTextField: - viewModel.username.value = textField.text + viewModel.username.value = text case displayNameTextField: - viewModel.displayname.value = textField.text + viewModel.displayName.value = text case emailTextField: - viewModel.email.value = textField.text + viewModel.email.value = text case passwordTextField: - viewModel.password.value = textField.text - default: break + viewModel.password.value = text + default: + break } } @@ -477,44 +502,34 @@ extension MastodonRegisterViewController: UITextFieldDelegate { textField.layer.shadowPath = UIBezierPath(roundedRect: textField.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 2.0, height: 2.0)).cgPath } - func validateAllTextField() -> Bool { - return viewModel.isUsernameValid.value ?? false && viewModel.isDisplaynameValid.value ?? false && viewModel.isEmailValid.value ?? false && viewModel.isPasswordValid.value ?? false - } - - private func setTextFieldValidAppearance(_ textField: UITextField, isValid: Bool?) { - guard let isValid = isValid else { - showShadowWithColor(color: .clear, textField: textField) - return - } - - if isValid { - showShadowWithColor(color: Asset.Colors.TextField.successGreen.color, textField: textField) - } else { - textField.shake() - showShadowWithColor(color: Asset.Colors.lightDangerRed.color, textField: textField) + private func setTextFieldValidAppearance(_ textField: UITextField, validateState: MastodonRegisterViewModel.ValidateState) { + switch validateState { + case .empty: + showShadowWithColor(color: textField.isFirstResponder ? Asset.Colors.TextField.highlight.color : .clear, textField: textField) + case .valid: + showShadowWithColor(color: Asset.Colors.TextField.valid.color, textField: textField) + case .invalid: + showShadowWithColor(color: Asset.Colors.TextField.invalid.color, textField: textField) } } } extension MastodonRegisterViewController { - @objc private func _resignFirstResponder() { - usernameTextField.resignFirstResponder() - displayNameTextField.resignFirstResponder() - emailTextField.resignFirstResponder() - passwordTextField.resignFirstResponder() + + @objc private func tapGestureRecognizerHandler(_ sender: UITapGestureRecognizer) { + view.endEditing(true) } @objc private func signUpButtonPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) - guard validateAllTextField(), - let username = viewModel.username.value, - let email = viewModel.email.value, - let password = viewModel.password.value else { - return - } + guard viewModel.isAllValid.value else { return } guard !viewModel.isRegistering.value else { return } viewModel.isRegistering.value = true + + let username = viewModel.username.value + let email = viewModel.email.value + let password = viewModel.password.value if let rules = viewModel.instance.rules, !rules.isEmpty { let mastodonServerRulesViewModel = MastodonServerRulesViewModel( @@ -564,4 +579,5 @@ extension MastodonRegisterViewController { } .store(in: &disposeBag) } + } diff --git a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift.orig b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift.orig new file mode 100644 index 00000000..df2d7d9f --- /dev/null +++ b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewController.swift.orig @@ -0,0 +1,577 @@ +// +// MastodonRegisterViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-2-5. +// + +import Combine +import MastodonSDK +import os.log +import UIKit +import UITextField_Shake + +final class MastodonRegisterViewController: UIViewController, NeedsDependency { + var disposeBag = Set() + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var viewModel: MastodonRegisterViewModel! + + let tapGestureRecognizer = UITapGestureRecognizer.singleTapGestureRecognizer + + let statusBarBackground: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + return view + }() + + let scrollView: UIScrollView = { + let scrollview = UIScrollView() + scrollview.showsVerticalScrollIndicator = false + scrollview.translatesAutoresizingMaskIntoConstraints = false + scrollview.keyboardDismissMode = .interactive + scrollview.clipsToBounds = false // make content could display over bleeding + return scrollview + }() + + let largeTitleLabel: UILabel = { + let label = UILabel() + label.font = UIFontMetrics(forTextStyle: .largeTitle).scaledFont(for: UIFont.boldSystemFont(ofSize: 34)) + label.textColor = Asset.Colors.Label.black.color + label.text = L10n.Scene.Register.title + return label + }() + + let photoView: UIView = { + let view = UIView() + view.backgroundColor = .clear + return view + }() + + let photoButton: UIButton = { + let button = UIButton(type: .custom) + let boldFont = UIFont.systemFont(ofSize: 42) + let configuration = UIImage.SymbolConfiguration(font: boldFont) + let image = UIImage(systemName: "person.fill.viewfinder", withConfiguration: configuration) + + button.setImage(image?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate), for: UIControl.State.normal) + button.imageView?.tintColor = Asset.Colors.Icon.photo.color + button.backgroundColor = .white + button.layer.cornerRadius = 45 + button.clipsToBounds = true + return button + }() + + let plusIconBackground: UIImageView = { + let icon = UIImageView() + let boldFont = UIFont.systemFont(ofSize: 24) + let configuration = UIImage.SymbolConfiguration(font: boldFont) + let image = UIImage(systemName: "plus.circle", withConfiguration: configuration) + icon.image = image + icon.tintColor = .white + return icon + }() + + let plusIcon: UIImageView = { + let icon = UIImageView() + let boldFont = UIFont.systemFont(ofSize: 24) + let configuration = UIImage.SymbolConfiguration(font: boldFont) + let image = UIImage(systemName: "plus.circle.fill", withConfiguration: configuration) + icon.image = image + icon.tintColor = Asset.Colors.Icon.plus.color + return icon + }() + + let domainLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .headline) + label.textColor = Asset.Colors.Label.black.color + return label + }() + + let usernameTextField: UITextField = { + let textField = UITextField() + + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.backgroundColor = .white + textField.textColor = .black + textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Username.placeholder, + attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.lightSecondaryText.color, + NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + textField.borderStyle = UITextField.BorderStyle.roundedRect + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) + textField.leftView = paddingView + textField.leftViewMode = .always + return textField + }() + + let usernameIsTakenLabel: UILabel = { + let label = UILabel() + return label + }() + + let displayNameTextField: UITextField = { + let textField = UITextField() + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.backgroundColor = .white + textField.textColor = .black + textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.DisplayName.placeholder, + attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.lightSecondaryText.color, + NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + textField.borderStyle = UITextField.BorderStyle.roundedRect + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) + textField.leftView = paddingView + textField.leftViewMode = .always + return textField + }() + + let emailTextField: UITextField = { + let textField = UITextField() + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.keyboardType = .emailAddress + textField.backgroundColor = .white + textField.textColor = .black + textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Email.placeholder, + attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.lightSecondaryText.color, + NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + textField.borderStyle = UITextField.BorderStyle.roundedRect + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) + textField.leftView = paddingView + textField.leftViewMode = .always + return textField + }() + + let passwordCheckLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + return label + }() + + let passwordTextField: UITextField = { + let textField = UITextField() + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.keyboardType = .asciiCapable + textField.isSecureTextEntry = true + textField.backgroundColor = .white + textField.textColor = .black + textField.attributedPlaceholder = NSAttributedString(string: L10n.Scene.Register.Input.Password.placeholder, + attributes: [NSAttributedString.Key.foregroundColor: Asset.Colors.lightSecondaryText.color, + NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .headline)]) + textField.borderStyle = UITextField.BorderStyle.roundedRect + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 5, height: textField.frame.height)) + textField.leftView = paddingView + textField.leftViewMode = .always + return textField + }() + + let signUpButton: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = .preferredFont(forTextStyle: .headline) + button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightBrandBlue.color), for: .normal) + button.setBackgroundImage(UIImage.placeholder(color: Asset.Colors.lightDisabled.color), for: .disabled) + button.isEnabled = false + button.setTitleColor(Asset.Colors.Label.primary.color, for: .normal) + button.setTitle(L10n.Common.Controls.Actions.continue, for: .normal) + button.layer.masksToBounds = true + button.layer.cornerRadius = 8 + button.layer.cornerCurve = .continuous + return button + }() + + let signUpActivityIndicatorView: UIActivityIndicatorView = { + let activityIndicatorView = UIActivityIndicatorView(style: .medium) + activityIndicatorView.hidesWhenStopped = true + return activityIndicatorView + }() +} + +extension MastodonRegisterViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + overrideUserInterfaceStyle = .light + view.backgroundColor = Asset.Colors.Background.onboardingBackground.color + domainLabel.text = "@" + viewModel.domain + " " + domainLabel.sizeToFit() + passwordCheckLabel.attributedText = viewModel.attributeStringForPassword() + usernameTextField.rightView = domainLabel + usernameTextField.rightViewMode = .always + usernameTextField.delegate = self + displayNameTextField.delegate = self + emailTextField.delegate = self + passwordTextField.delegate = self + + // gesture + view.addGestureRecognizer(tapGestureRecognizer) + tapGestureRecognizer.addTarget(self, action: #selector(_resignFirstResponder)) + + // stackview + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fill + stackView.spacing = 40 +<<<<<<< HEAD + stackView.layoutMargins = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0) +======= + stackView.layoutMargins = UIEdgeInsets(top: 0, left: 4, bottom: 26, right: 4) +>>>>>>> feature/signup + stackView.isLayoutMarginsRelativeArrangement = true + stackView.addArrangedSubview(largeTitleLabel) + stackView.addArrangedSubview(photoView) + stackView.addArrangedSubview(usernameTextField) + stackView.addArrangedSubview(displayNameTextField) + stackView.addArrangedSubview(emailTextField) + stackView.addArrangedSubview(passwordTextField) + stackView.addArrangedSubview(passwordCheckLabel) + + // scrollView + view.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + view.readableContentGuide.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor), + scrollView.frameLayoutGuide.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), + ]) + + // stackview + scrollView.addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.widthAnchor.constraint(equalTo: scrollView.contentLayoutGuide.widthAnchor), + scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + ]) + + statusBarBackground.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(statusBarBackground) + NSLayoutConstraint.activate([ + statusBarBackground.topAnchor.constraint(equalTo: view.topAnchor), + statusBarBackground.leadingAnchor.constraint(equalTo: view.leadingAnchor), + statusBarBackground.trailingAnchor.constraint(equalTo: view.trailingAnchor), + statusBarBackground.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + ]) + + // photoview + photoView.translatesAutoresizingMaskIntoConstraints = false + photoView.addSubview(photoButton) + NSLayoutConstraint.activate([ + photoView.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + ]) + photoButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + photoButton.heightAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + photoButton.widthAnchor.constraint(equalToConstant: 90).priority(.defaultHigh), + photoButton.centerXAnchor.constraint(equalTo: photoView.centerXAnchor), + photoButton.centerYAnchor.constraint(equalTo: photoView.centerYAnchor), + ]) + plusIconBackground.translatesAutoresizingMaskIntoConstraints = false + photoView.addSubview(plusIconBackground) + NSLayoutConstraint.activate([ + plusIconBackground.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor), + plusIconBackground.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor), + ]) + plusIcon.translatesAutoresizingMaskIntoConstraints = false + photoView.addSubview(plusIcon) + NSLayoutConstraint.activate([ + plusIcon.trailingAnchor.constraint(equalTo: photoButton.trailingAnchor), + plusIcon.bottomAnchor.constraint(equalTo: photoButton.bottomAnchor), + ]) + + // textfield + NSLayoutConstraint.activate([ + usernameTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + displayNameTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + emailTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + passwordTextField.heightAnchor.constraint(equalToConstant: 50).priority(.defaultHigh), + ]) + + // password + stackView.setCustomSpacing(6, after: passwordTextField) + stackView.setCustomSpacing(32, after: passwordCheckLabel) + + // button + signUpButton.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(signUpButton) + NSLayoutConstraint.activate([ + signUpButton.heightAnchor.constraint(equalToConstant: 46).priority(.defaultHigh), + ]) + + signUpActivityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(signUpActivityIndicatorView) + NSLayoutConstraint.activate([ + signUpActivityIndicatorView.centerXAnchor.constraint(equalTo: signUpButton.centerXAnchor), + signUpActivityIndicatorView.centerYAnchor.constraint(equalTo: signUpButton.centerYAnchor), + ]) + + Publishers.CombineLatest( + KeyboardResponderService.shared.state.eraseToAnyPublisher(), + KeyboardResponderService.shared.willEndFrame.eraseToAnyPublisher() + ) + .sink(receiveValue: { [weak self] state, endFrame in + guard let self = self else { return } + +<<<<<<< HEAD + guard isShow, state == .dock else { + self.scrollView.contentInset.bottom = 0.0 + self.scrollView.verticalScrollIndicatorInsets.bottom = 0.0 + return + } + + // isShow AND dock state + let contentFrame = self.view.convert(self.scrollView.frame, to: nil) +======= + guard state == .dock else { + self.scrollview.contentInset.bottom = 0.0 + self.scrollview.verticalScrollIndicatorInsets.bottom = 0.0 + return + } + + let contentFrame = self.view.convert(self.scrollview.frame, to: nil) +>>>>>>> feature/signup + let padding = contentFrame.maxY - endFrame.minY + guard padding > 0 else { + self.scrollView.contentInset.bottom = 0.0 + self.scrollView.verticalScrollIndicatorInsets.bottom = 0.0 + return + } + + self.scrollView.contentInset.bottom = padding + 16 + self.scrollView.verticalScrollIndicatorInsets.bottom = padding + 16 + }) + .store(in: &disposeBag) + + viewModel.isRegistering + .receive(on: DispatchQueue.main) + .sink { [weak self] isRegistering in + guard let self = self else { return } + isRegistering ? self.signUpActivityIndicatorView.startAnimating() : self.signUpActivityIndicatorView.stopAnimating() + self.signUpButton.setTitle(isRegistering ? "" : L10n.Common.Controls.Actions.continue, for: .normal) + self.signUpButton.isEnabled = !isRegistering + } + .store(in: &disposeBag) + + viewModel.isUsernameValid + .receive(on: DispatchQueue.main) + .sink { [weak self] isValid in + guard let self = self else { return } + self.setTextFieldValidAppearance(self.usernameTextField, isValid: isValid) + } + .store(in: &disposeBag) + viewModel.isDisplaynameValid + .receive(on: DispatchQueue.main) + .sink { [weak self] isValid in + guard let self = self else { return } + self.setTextFieldValidAppearance(self.displayNameTextField, isValid: isValid) + } + .store(in: &disposeBag) + viewModel.isEmailValid + .receive(on: DispatchQueue.main) + .sink { [weak self] isValid in + guard let self = self else { return } + self.setTextFieldValidAppearance(self.emailTextField, isValid: isValid) + } + .store(in: &disposeBag) + viewModel.isPasswordValid + .receive(on: DispatchQueue.main) + .sink { [weak self] isValid in + guard let self = self else { return } + self.setTextFieldValidAppearance(self.passwordTextField, isValid: isValid) + } + .store(in: &disposeBag) + + Publishers.CombineLatest4( + viewModel.isUsernameValid, + viewModel.isDisplaynameValid, + viewModel.isEmailValid, + viewModel.isPasswordValid + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isUsernameValid, isDisplaynameValid, isEmailValid, isPasswordValid in + guard let self = self else { return } + self.signUpButton.isEnabled = isUsernameValid ?? false && isDisplaynameValid ?? false && isEmailValid ?? false && isPasswordValid ?? false + } + .store(in: &disposeBag) + + viewModel.error + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] error in + guard let self = self else { return } + let alertController = UIAlertController(error, preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) + self.coordinator.present( + scene: .alertController(alertController: alertController), + from: nil, + transition: .alertController(animated: true, completion: nil) + ) + } + .store(in: &disposeBag) + + NotificationCenter.default + .publisher(for: UITextField.textDidChangeNotification, object: passwordTextField) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard let text = self.passwordTextField.text else { return } + let validations = self.viewModel.validatePassword(text: text) + self.passwordCheckLabel.attributedText = self.viewModel.attributeStringForPassword(eightCharacters: validations.0, oneNumber: validations.1, oneSpecialCharacter: validations.2) + } + .store(in: &disposeBag) + + signUpButton.addTarget(self, action: #selector(MastodonRegisterViewController.signUpButtonPressed(_:)), for: .touchUpInside) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + navigationController?.setNavigationBarHidden(true, animated: false) + } +} + +extension MastodonRegisterViewController: UITextFieldDelegate { + func textFieldDidBeginEditing(_ textField: UITextField) { + // align to password label when overlap + if textField === passwordTextField, + KeyboardResponderService.shared.isShow.value, +<<<<<<< HEAD + KeyboardResponderService.shared.state.value == .dock { + let endFrame = KeyboardResponderService.shared.endFrame.value + let contentFrame = self.scrollView.convert(self.passwordCheckLabel.frame, to: nil) +======= + KeyboardResponderService.shared.state.value == .dock + { + let endFrame = KeyboardResponderService.shared.willEndFrame.value + let contentFrame = scrollview.convert(passwordCheckLabel.frame, to: nil) +>>>>>>> feature/signup + let padding = contentFrame.maxY - endFrame.minY + if padding > 0 { + let contentOffsetY = scrollView.contentOffset.y + DispatchQueue.main.async { + self.scrollView.setContentOffset(CGPoint(x: 0, y: contentOffsetY + padding + 16.0), animated: true) + } + } + } + } + + func textFieldDidEndEditing(_ textField: UITextField) { + switch textField { + case usernameTextField: + viewModel.username.value = textField.text + case displayNameTextField: + viewModel.displayname.value = textField.text + case emailTextField: + viewModel.email.value = textField.text + case passwordTextField: + viewModel.password.value = textField.text + default: break + } + } + + func showShadowWithColor(color: UIColor, textField: UITextField) { + // To apply Shadow + textField.layer.shadowOpacity = 1 + textField.layer.shadowRadius = 2.0 + textField.layer.shadowOffset = CGSize.zero + textField.layer.shadowColor = color.cgColor + textField.layer.shadowPath = UIBezierPath(roundedRect: textField.bounds, byRoundingCorners: .allCorners, cornerRadii: CGSize(width: 2.0, height: 2.0)).cgPath + } + + func validateAllTextField() -> Bool { + return viewModel.isUsernameValid.value ?? false && viewModel.isDisplaynameValid.value ?? false && viewModel.isEmailValid.value ?? false && viewModel.isPasswordValid.value ?? false + } + + private func setTextFieldValidAppearance(_ textField: UITextField, isValid: Bool?) { + guard let isValid = isValid else { + showShadowWithColor(color: .clear, textField: textField) + return + } + + if isValid { + showShadowWithColor(color: Asset.Colors.TextField.successGreen.color, textField: textField) + } else { + textField.shake() + showShadowWithColor(color: Asset.Colors.lightDangerRed.color, textField: textField) + } + } +} + +extension MastodonRegisterViewController { + @objc private func _resignFirstResponder() { + usernameTextField.resignFirstResponder() + displayNameTextField.resignFirstResponder() + emailTextField.resignFirstResponder() + passwordTextField.resignFirstResponder() + } + + @objc private func signUpButtonPressed(_ sender: UIButton) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", (#file as NSString).lastPathComponent, #line, #function) + guard validateAllTextField(), + let username = viewModel.username.value, + let email = viewModel.email.value, + let password = viewModel.password.value else { + return + } + + guard !viewModel.isRegistering.value else { return } + viewModel.isRegistering.value = true + + if let rules = viewModel.instance.rules, !rules.isEmpty { + let mastodonServerRulesViewModel = MastodonServerRulesViewModel( + context: context, + domain: viewModel.domain, + rules: rules + ) + coordinator.present(scene: .mastodonServerRules(viewModel: mastodonServerRulesViewModel), from: self, transition: .show) + return + } + + let query = Mastodon.API.Account.RegisterQuery( + reason: nil, + username: username, + email: email, + password: password, + agreement: true, // TODO: + locale: "en" // TODO: + ) + + context.apiService.accountRegister( + domain: viewModel.domain, + query: query, + authorization: viewModel.applicationAuthorization + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + self.viewModel.isRegistering.value = false + switch completion { + case .failure(let error): + self.viewModel.error.send(error) + case .finished: + break + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + _ = response.value + // TODO: + let alertController = UIAlertController(title: "Success", message: "Regsiter request sent. Please check your email.\n(Auto sign in not implement yet.)", preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default) { [weak self] _ in + guard let self = self else { return } + self.navigationController?.popViewController(animated: true) + } + alertController.addAction(okAction) + self.coordinator.present(scene: .alertController(alertController: alertController), from: self, transition: .alertController(animated: true, completion: nil)) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewModel.swift index daea2f53..a4d79195 100644 --- a/Mastodon/Scene/Authentication/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Authentication/Register/MastodonRegisterViewModel.swift @@ -11,6 +11,7 @@ import MastodonSDK import UIKit final class MastodonRegisterViewModel { + var disposeBag = Set() // input @@ -19,19 +20,26 @@ final class MastodonRegisterViewModel { let instance: Mastodon.Entity.Instance let applicationToken: Mastodon.Entity.Token + let username = CurrentValueSubject("") + let displayName = CurrentValueSubject("") + let email = CurrentValueSubject("") + let password = CurrentValueSubject("") + let isUsernameValidateDalay = CurrentValueSubject(true) + let isDisplayNameValidateDalay = CurrentValueSubject(true) + let isEmailValidateDalay = CurrentValueSubject(true) + let isPasswordValidateDalay = CurrentValueSubject(true) let isRegistering = CurrentValueSubject(false) - let username = CurrentValueSubject(nil) - let displayname = CurrentValueSubject(nil) - let email = CurrentValueSubject(nil) - let password = CurrentValueSubject(nil) + // output let applicationAuthorization: Mastodon.API.OAuth.Authorization - let isUsernameValid = CurrentValueSubject(nil) - let isDisplaynameValid = CurrentValueSubject(nil) - let isEmailValid = CurrentValueSubject(nil) - let isPasswordValid = CurrentValueSubject(nil) + let usernameValidateState = CurrentValueSubject(.empty) + let displayNameValidateState = CurrentValueSubject(.empty) + let emailValidateState = CurrentValueSubject(.empty) + let passwordValidateState = CurrentValueSubject(.empty) + + let isAllValid = CurrentValueSubject(false) let error = CurrentValueSubject(nil) @@ -49,61 +57,75 @@ final class MastodonRegisterViewModel { username .map { username in - guard let username = username else { - return nil + guard !username.isEmpty else { return .empty } + var isValid = true + + // regex opt-out way to check validation + // allowed: + // a-z (isASCII && isLetter) + // A-Z (isASCII && isLetter) + // 0-9 (isASCII && isNumber) + // _ ("_") + for char in username { + guard char.isASCII, (char.isLetter || char.isNumber || char == "_") else { + isValid = false + break + } } - return !username.isEmpty + return isValid ? .valid : .invalid } - .assign(to: \.value, on: isUsernameValid) + .assign(to: \.value, on: usernameValidateState) .store(in: &disposeBag) - displayname + displayName .map { displayname in - guard let displayname = displayname else { - return nil - } - return !displayname.isEmpty + guard !displayname.isEmpty else { return .empty } + return .valid } - .assign(to: \.value, on: isDisplaynameValid) + .assign(to: \.value, on: displayNameValidateState) .store(in: &disposeBag) email - .map { [weak self] email in - guard let self = self else { return nil } - guard let email = email else { - return nil - } - return !email.isEmpty && self.isValidEmail(email) + .map { email in + guard !email.isEmpty else { return .empty } + return MastodonRegisterViewModel.isValidEmail(email) ? .valid : .invalid } - .assign(to: \.value, on: isEmailValid) + .assign(to: \.value, on: emailValidateState) .store(in: &disposeBag) password - .map { [weak self] password in - guard let self = self else { return nil } - guard let password = password else { - return nil - } - let result = self.validatePassword(text: password) - return !password.isEmpty && result.0 && result.1 && result.2 + .map { password in + guard !password.isEmpty else { return .empty } + return password.count >= 8 ? .valid : .invalid } - .assign(to: \.value, on: isPasswordValid) + .assign(to: \.value, on: passwordValidateState) .store(in: &disposeBag) + + Publishers.CombineLatest4( + usernameValidateState.eraseToAnyPublisher(), + displayNameValidateState.eraseToAnyPublisher(), + emailValidateState.eraseToAnyPublisher(), + passwordValidateState.eraseToAnyPublisher() + ) + .map { $0.0 == .valid && $0.1 == .valid && $0.2 == .valid && $0.3 == .valid } + .assign(to: \.value, on: isAllValid) + .store(in: &disposeBag) + } + +} + +extension MastodonRegisterViewModel { + enum ValidateState { + case empty + case invalid + case valid } } extension MastodonRegisterViewModel { - func isValidEmail(_ email: String) -> Bool { + static func isValidEmail(_ email: String) -> Bool { let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format: "SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: email) } - - func validatePassword(text: String) -> (Bool, Bool, Bool) { - let trimmedText = text.trimmingCharacters(in: .whitespaces) - let isEightCharacters = trimmedText.count >= 8 - let isOneNumber = trimmedText.range(of: ".*[0-9]", options: .regularExpression) != nil - let isOneSpecialCharacter = trimmedText.trimmingCharacters(in: .decimalDigits).trimmingCharacters(in: .letters).count > 0 - return (isEightCharacters, isOneNumber, isOneSpecialCharacter) - } func attributeStringForUsername() -> NSAttributedString { let resultAttributeString = NSMutableAttributedString() @@ -118,7 +140,7 @@ extension MastodonRegisterViewModel { return resultAttributeString } - func attributeStringForPassword(eightCharacters: Bool = false, oneNumber: Bool = false, oneSpecialCharacter: Bool = false) -> NSAttributedString { + func attributeStringForPassword(eightCharacters: Bool = false) -> NSAttributedString { let font = UIFont.preferredFont(forTextStyle: .caption1) let color = UIColor.black let falseColor = UIColor.clear @@ -128,17 +150,9 @@ extension MastodonRegisterViewModel { attributeString.append(start) attributeString.append(checkmarkImage(color: eightCharacters ? color : falseColor)) - let eightCharactersDescription = NSAttributedString(string: "Eight characters\n", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) + let eightCharactersDescription = NSAttributedString(string: " Eight characters\n", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) attributeString.append(eightCharactersDescription) - attributeString.append(checkmarkImage(color: oneNumber ? color : falseColor)) - let oneNumberDescription = NSAttributedString(string: "One number\n", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) - attributeString.append(oneNumberDescription) - - attributeString.append(checkmarkImage(color: oneSpecialCharacter ? color : falseColor)) - let oneSpecialCharacterDescription = NSAttributedString(string: "One special character\n", attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: color]) - attributeString.append(oneSpecialCharacterDescription) - return attributeString }