添加网络层并接入 v9 登录流程及单元测试
This commit is contained in:
@ -10,9 +10,11 @@ import UIKit
|
||||
final class LoginViewController: BaseViewController {
|
||||
|
||||
private let viewModel = LoginViewModel()
|
||||
private let authAPI = NetworkServices.shared.authAPI
|
||||
|
||||
private let backgroundImageView = UIImageView()
|
||||
private let welcomeLabel = UILabel()
|
||||
private let formCardShadowView = UIView()
|
||||
private let formCardView = UIView()
|
||||
private let topFormStack = UIStackView()
|
||||
private let accountField = LoginTextField(placeholder: "请输入账号")
|
||||
@ -24,7 +26,7 @@ final class LoginViewController: BaseViewController {
|
||||
private let registerPrefixLabel = UILabel()
|
||||
private let registerButton = UIButton(type: .system)
|
||||
|
||||
private var hasPlayedEntranceAnimation = false
|
||||
private weak var accountSelectionController: AccountSelectionViewController?
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
.lightContent
|
||||
@ -46,6 +48,12 @@ final class LoginViewController: BaseViewController {
|
||||
welcomeLabel.font = .systemFont(ofSize: 28, weight: .semibold)
|
||||
welcomeLabel.textColor = .white
|
||||
|
||||
formCardShadowView.backgroundColor = .clear
|
||||
formCardShadowView.layer.shadowColor = UIColor.black.cgColor
|
||||
formCardShadowView.layer.shadowOpacity = 0.14
|
||||
formCardShadowView.layer.shadowOffset = CGSize(width: 0, height: 8)
|
||||
formCardShadowView.layer.shadowRadius = 20
|
||||
|
||||
formCardView.backgroundColor = .white
|
||||
formCardView.layer.cornerRadius = 16
|
||||
formCardView.clipsToBounds = true
|
||||
@ -75,7 +83,8 @@ final class LoginViewController: BaseViewController {
|
||||
|
||||
view.addSubview(backgroundImageView)
|
||||
view.addSubview(welcomeLabel)
|
||||
view.addSubview(formCardView)
|
||||
view.addSubview(formCardShadowView)
|
||||
formCardShadowView.addSubview(formCardView)
|
||||
formCardView.addSubview(topFormStack)
|
||||
formCardView.addSubview(registerContainer)
|
||||
|
||||
@ -89,6 +98,12 @@ final class LoginViewController: BaseViewController {
|
||||
topFormStack.setCustomSpacing(8, after: loginButton)
|
||||
topFormStack.addArrangedSubview(loginByCodeButton)
|
||||
|
||||
viewModel.applyStoredPreferences()
|
||||
if let username = viewModel.normalizedUsername.nonEmpty ?? viewModel.account.nonEmpty {
|
||||
accountField.text = username
|
||||
}
|
||||
agreementRow.isChecked = viewModel.isPrivacyChecked
|
||||
|
||||
bindViewModel()
|
||||
updateUI()
|
||||
}
|
||||
@ -103,12 +118,16 @@ final class LoginViewController: BaseViewController {
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
|
||||
formCardView.snp.makeConstraints { make in
|
||||
formCardShadowView.snp.makeConstraints { make in
|
||||
make.top.equalTo(welcomeLabel.snp.bottom).offset(48)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-24)
|
||||
}
|
||||
|
||||
formCardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
topFormStack.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
@ -120,6 +139,14 @@ final class LoginViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
formCardShadowView.layer.shadowPath = UIBezierPath(
|
||||
roundedRect: formCardShadowView.bounds,
|
||||
cornerRadius: 16
|
||||
).cgPath
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
accountField.onTextChange = { [weak self] text in
|
||||
self?.viewModel.updateAccount(text)
|
||||
@ -127,6 +154,12 @@ final class LoginViewController: BaseViewController {
|
||||
accountField.onReturnKey = { [weak self] in
|
||||
self?.passwordField.becomeFirstResponder()
|
||||
}
|
||||
accountField.onEditingDidEnd = { [weak self] in
|
||||
self?.viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||
if let username = self?.viewModel.normalizedUsername {
|
||||
self?.accountField.text = username
|
||||
}
|
||||
}
|
||||
|
||||
passwordField.onTextChange = { [weak self] text in
|
||||
self?.viewModel.updatePassword(text)
|
||||
@ -145,11 +178,24 @@ final class LoginViewController: BaseViewController {
|
||||
agreementRow.onPrivacyPolicyTap = { [weak self] in
|
||||
self?.viewModel.onPrivacyPolicy?()
|
||||
}
|
||||
|
||||
setupDismissKeyboardGesture()
|
||||
}
|
||||
|
||||
private func setupDismissKeyboardGesture() {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
|
||||
tap.cancelsTouchesInView = false
|
||||
tap.delegate = self
|
||||
view.addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
@objc private func dismissKeyboard() {
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
playEntranceAnimationIfNeeded()
|
||||
Task { await viewModel.loadAppConfig(authAPI: authAPI) }
|
||||
}
|
||||
|
||||
private func bindViewModel() {
|
||||
@ -160,7 +206,7 @@ final class LoginViewController: BaseViewController {
|
||||
self?.showToast(message)
|
||||
}
|
||||
viewModel.onLogin = { [weak self] in
|
||||
self?.showToast("登录功能待接入")
|
||||
self?.performLogin()
|
||||
}
|
||||
viewModel.onLoginByCode = { [weak self] in
|
||||
self?.showToast("验证码登录待接入")
|
||||
@ -178,7 +224,8 @@ final class LoginViewController: BaseViewController {
|
||||
|
||||
private func updateUI() {
|
||||
agreementRow.isChecked = viewModel.isPrivacyChecked
|
||||
loginButton.isEnabled = viewModel.isLoginEnabled
|
||||
loginButton.isEnabled = viewModel.isLoginEnabled && !viewModel.isLoading
|
||||
loginButton.alpha = viewModel.isLoading ? 0.7 : 1
|
||||
|
||||
let showRegisterSection = viewModel.enableRegister
|
||||
loginByCodeButton.isHidden = !showRegisterSection
|
||||
@ -192,21 +239,87 @@ final class LoginViewController: BaseViewController {
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
if let payload = viewModel.pendingAccountSelection {
|
||||
if let controller = accountSelectionController {
|
||||
controller.updateLoading(viewModel.isSelectingAccount)
|
||||
} else if presentedViewController == nil {
|
||||
presentAccountSelection(payload)
|
||||
}
|
||||
} else if accountSelectionController != nil {
|
||||
dismiss(animated: true)
|
||||
accountSelectionController = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func playEntranceAnimationIfNeeded() {
|
||||
guard !hasPlayedEntranceAnimation else { return }
|
||||
hasPlayedEntranceAnimation = true
|
||||
private func performLogin() {
|
||||
viewModel.normalizeUsernameCountryCodeIfNeeded()
|
||||
accountField.text = viewModel.normalizedUsername
|
||||
|
||||
formCardView.transform = CGAffineTransform(translationX: 0, y: view.bounds.height)
|
||||
welcomeLabel.alpha = 0
|
||||
Task {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
|
||||
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) {
|
||||
self.formCardView.transform = .identity
|
||||
self.welcomeLabel.alpha = 1
|
||||
do {
|
||||
let resolution = try await viewModel.login(authAPI: authAPI)
|
||||
switch resolution {
|
||||
case let .completed(response):
|
||||
completeLogin(with: response)
|
||||
case .needsAccountSelection:
|
||||
break
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAccountSelection(_ payload: AccountSelectionPayload) {
|
||||
let controller = AccountSelectionViewController(
|
||||
payload: payload,
|
||||
isLoading: viewModel.isSelectingAccount,
|
||||
onCancel: { [weak self] in
|
||||
self?.viewModel.clearPendingAccountSelection()
|
||||
self?.accountSelectionController = nil
|
||||
},
|
||||
onConfirm: { [weak self] account in
|
||||
self?.selectAccount(account)
|
||||
}
|
||||
)
|
||||
accountSelectionController = controller
|
||||
let navigation = UINavigationController(rootViewController: controller)
|
||||
navigation.modalPresentationStyle = .formSheet
|
||||
present(navigation, animated: true)
|
||||
}
|
||||
|
||||
private func selectAccount(_ account: AccountSwitchAccount) {
|
||||
Task {
|
||||
accountSelectionController?.updateLoading(true)
|
||||
defer { accountSelectionController?.updateLoading(false) }
|
||||
|
||||
do {
|
||||
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
|
||||
completeLogin(with: response)
|
||||
accountSelectionController = nil
|
||||
dismiss(animated: true)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func completeLogin(with response: V9AuthResponse) {
|
||||
AuthSessionHelper.completeLogin(
|
||||
with: response,
|
||||
username: viewModel.normalizedUsername,
|
||||
privacyAgreementAccepted: viewModel.isPrivacyChecked
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func loginButtonTapped() {
|
||||
view.endEditing(true)
|
||||
viewModel.login()
|
||||
@ -222,3 +335,24 @@ final class LoginViewController: BaseViewController {
|
||||
viewModel.onRegister?()
|
||||
}
|
||||
}
|
||||
|
||||
extension LoginViewController: UIGestureRecognizerDelegate {
|
||||
/// 点击输入框、按钮时不触发收键盘,避免干扰正常交互。
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
var candidate: UIView? = touch.view
|
||||
while let view = candidate {
|
||||
if view is UITextField || view is UIButton {
|
||||
return false
|
||||
}
|
||||
candidate = view.superview
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user